create-a-ui

% Creating a New MUGS UI

Reviewed 2021-05-21 by japhb

There are actually two common cases here:

  1. Creating a new UI type that has never been used with MUGS before and a driver app for players wanting to use that UI; for example, adding a Qt or Win32 UI type

  2. Creating a game UI plugin for an existing UI type for an existing game that doesn't run in that UI yet; for example, adding a TUI plugin for a MUGS action game that previously only worked in a graphical UI such as SDL or GTK+

Creating a New UI Type

This is the path to take when you want to introduce support for an additional UI toolkit (such as Qt or Win32), or add a whole new interface paradigm to MUGS (such as VR display or multitouch tablet support). For concreteness below, we'll walk through the creation of the actual MUGS-UI-Pop repo.

Raku Support for the UI

The first task is to ensure that the new UI is supported in Raku at all; if the API isn't available as a module (or module suite) that you can depend on, address that first -- creating MUGS-specific narrow bindings for a major toolkit API is possible, but probably a frustrating yak-shave. Besides, making a general binding is much better for the overall Raku community.

Pop is a Raku-native game UI toolkit, and has built-in bindings for all of the SDL functionality it uses, so as long as we have the needed SDL libraries available it should Just Work.

Getting Familiar with the UI API

If you're new to the interface type or toolkit API, take some time to familiarize yourself with the basics. Try some tutorials or simple test apps in the new API before starting on the MUGS plugin.

Web UIs can be supported via Cro (which is already a MUGS dependency), but if you plan to use a highly dynamic Javascript framework with it, you should get comfortable with that framework before trying to add MUGS support for it.

Pop includes module docs and tutorial examples in the main repo, and there are additional repos with mini games and a full platformer. There's enough there to get a good basic familiarity.

Create a New UI Repo

Each UI should be in its own repo, so you'll need to create one using one of these patterns:

MUGS-UI-Web<Foo> : For a Foo UI that runs in a web browser, such as MUGS-UI-WebSimple or MUGS-UI-WebCanvas

MUGS-UI-<Bar> : For any local Bar UI toolkit, such as MUGS-UI-CLI or MUGS-UI-GTK

The MUGS development tool mugs-tool can give you a starting point for this; here I'll use it to create the Pop UI repo:

$ mugs-tool new-ui-type Pop

=== mi6 new "MUGS::UI::Pop"
Successfully created MUGS-UI-Pop

=== git commit -m "Initial mi6 template"
[master (root-commit) 8beb64d] Initial mi6 template
 9 files changed, 356 insertions(+)
 create mode 100644 .github/workflows/test.yml
 create mode 100644 .gitignore
 create mode 100644 Changes
 create mode 100644 LICENSE
 create mode 100644 META6.json
 create mode 100644 README.md
 create mode 100644 dist.ini
 create mode 100644 lib/MUGS/UI/Pop.rakumod
 create mode 100644 t/01-basic.rakutest

=== git branch -M main

=== Creating directories
docs
lib/MUGS/App
lib/MUGS/UI/Pop
lib/MUGS/UI/Pop/Game
lib/MUGS/UI/Pop/Genre

=== Adding/updating MUGS-specific files
bin/mugs-pop
lib/MUGS/App/Pop.rakumod
lib/MUGS/UI/Pop.rakumod
t/00-use.rakutest

=== chmod "+x" "bin/mugs-pop"

=== mi6 build

=== git add "bin/mugs-pop" "lib/MUGS/App/Pop.rakumod" "lib/MUGS/UI/Pop.rakumod" "t/00-use.rakutest" "META6.json"

=== git rm "t/01-basic.rakutest"
rm 't/01-basic.rakutest'

=== git commit -m "Add MUGS-specific files and tweaks"
[main cc088c4] Add MUGS-specific files and tweaks
 5 files changed, 108 insertions(+), 8 deletions(-)
 create mode 100755 bin/mugs-pop
 create mode 100644 lib/MUGS/App/Pop.rakumod
 create mode 100644 t/00-use.rakutest
 delete mode 100644 t/01-basic.rakutest

=== git status
On branch main
nothing to commit, working tree clean

--> All commands executed successfully.

Review Skeleton

Take a look around the newly generated tree and get acquainted with the contents. Here's a quick overview:

MUGS-UI-Pop                   # New UI repo root
ā”œā”€ā”€ bin                       # Minimal launcher scripts
│   └── mugs-pop              # Launcher script for Pop UI
ā”œā”€ā”€ Changes                   # (Hand-updated) record of version changes
ā”œā”€ā”€ dist.ini                  # mi6 config file
ā”œā”€ā”€ docs                      # Stub for docs tree
ā”œā”€ā”€ .github
│   └── workflows             # GitHub Workflows
│       └── test.yml          # On-commit tests
ā”œā”€ā”€ .gitignore                # Patterns for files to ignore in repo
ā”œā”€ā”€ lib
│   └── MUGS                  # Main MUGS code libraries
│       ā”œā”€ā”€ App               # App implementations
│       │   └── Pop.rakumod   # Pop UI app
│       └── UI                # Game UIs
│           ā”œā”€ā”€ Pop.rakumod   # Base class for all Pop game UIs
│           └── Pop
│               ā”œā”€ā”€ Game      # Game-specific UI plugins
│               └── Genre     # Genre-shared UI code
ā”œā”€ā”€ LICENSE                   # Artistic License 2.0
ā”œā”€ā”€ META6.json                # Module metadata
ā”œā”€ā”€ README.md                 # Auto-generated from MUGS::UI::Pop docs
└── t                         # User-visible tests
    └── 00-use.rakutest       # Use all modules and versioned dependencies

Almost all of the code resides in lib/MUGS/; here's the entire contents of the launcher script, bin/mugs-pop:

#!/usr/bin/env raku
use v6.d;
BEGIN put 'Loading MUGS.' if $*OUT.t;
use MUGS::App::Pop;

We'll take a look at the Pop.rakumod modules below.

Tweak the New UI Repo

After you've created the initial skeleton, you can make any changes to your personal preferences (or emulate the first few commits of one of the MUGS core UI repos), and commit them so you're back at a clean git status for the next step. Here's what I did for the Pop UI:

$ cd MUGS-UI-Pop

$ # I use a doc auto-converter, so ignore its output
$ cat >> .gitignore

# Generated files
/*.html
/docs/*.html
/docs/*/*.html
/docs/*/*.dot.svg
$ git add .gitignore
$ git commit -m "Add generated files to .gitignore"

$ # Add MUGS standard CODE_OF_CONDUCT and CONTRIBUTING docs
$ cp ../MUGS-UI-CLI/CODE_OF_CONDUCT.md .
$ cp ../MUGS-UI-CLI/docs/CONTRIBUTING.md docs/
$ git add CODE_OF_CONDUCT.md docs/CONTRIBUTING.md
$ git commit -m "Add MUGS standard CODE_OF_CONDUCT and CONTRIBUTING"

What's Working?

Time to try the skeletal app:

$ raku -Ilib bin/mugs-pop
Loading MUGS.
Usage:
  bin/mugs-pop <game-type> -- Play a requested Pop game

Common options for all commands:
  --server=<Str>    Specify an external server (defaults to internal)
  --universe=<Str>  Specify a local universe (internal server only)
  --debug           Enable debug output

It wants the user to specify a game-type to play, but we haven't implemented any game plugins for this new UI yet, so that will have to wait. Here's what happens when trying to request a particular game-type but the plugin doesn't exist yet:

$ raku -Ilib bin/mugs-pop lobby
Loading MUGS.
Cannot launch 'lobby'; missing UI plugin.

In other words, it doesn't crash, but it also doesn't do much. Time to fix that.

Stub the New UI

mugs-tool new-ui-type tries to relieve some of the boilerplate toil involved in creating a new UI type, but since UI toolkits vary considerably there are many pieces that must be filled in by a human.

Here's the App file as it was generated by mugs-tool; particularly note the lines marked with # XXXX:

# ABSTRACT: Core logic to set up and run a Pop game

# XXXX: use Pop essential app modules here

use MUGS::Core;
use MUGS::App::LocalUI;
use MUGS::UI::Pop;


# Use subcommand MAIN args
%PROCESS::SUB-MAIN-OPTS = :named-anywhere;


#| Pop app
class MUGS::App::Pop is MUGS::App::LocalUI {
    has MUGS::UI::Pop::Game $.current-game;
    # XXXX: Add additional app-wide state attributes here

    method ui-type() { 'Pop' }

    #| Initialize the overall MUGS client app
    method initialize() {
        callsame;
        # XXXX: Extend to initialize toolkit and Pop-specific globals
    }

    #| Shut down the overall MUGS client app (as cleanly as possible)
    method shutdown() {
        # XXXX: Extend to stop Pop
        callsame;
    }

    #| Connect to server and authenticate as a valid user
    method ensure-authenticated-session(Str $server, Str $universe) {
        my $decoded = self.decode-and-connect($server, $universe);
        my ($username, $password) = self.initial-userpass($decoded);

        # XXXX: Should allow player to correct errors and retry or exit
        await $.session.authenticate(:$username, :$password);
    }

    #| Create and initialize a new game UI for a given game type and client
    method launch-game-ui(Str:D :$game-type, MUGS::Client::Game:D :$client) {
        $!current-game = callsame;
    }

    #| Start actively playing current game UI
    method play-current-game() {
        # XXXX: Enter Pop main loop here, or (by default) hand to game UI
        $!current-game.main-loop;
    }
}


#| Common options that work for all subcommands
my $common-args = :(Str :$server, Str :$universe, Bool :$debug);

#| Add description of common arguments/options to standard USAGE
sub GENERATE-USAGE(&main, |capture) is export {
    &*GENERATE-USAGE(&main, |capture).subst(' <options>', '', :g)
    ~ q:to/OPTIONS/.trim-trailing;


        Common options for all commands:
          --server=<Str>    Specify an external server (defaults to internal)
          --universe=<Str>  Specify a local universe (internal server only)
          --debug           Enable debug output
        OPTIONS
}


#| Play a requested Pop game
multi MAIN($game-type, |options where $common-args) is export {
    play-via-local-ui(MUGS::App::Pop, :$game-type, |options)
}

The last 20 lines of the file will work just fine for now; they provide the default command line options and argument behavior. The header and class are where all the interesting changes are needed. First load the UI toolkit's essential modules at the top:

use Pop;
use Pop::Graphics;

Second, override the initialize and shutdown methods to add handling of the Pop window. Since the Pop object is a self-bootstrapping singleton object, initialize can call Pop.new without storing the result, and for now at least there's no additional app-global attributes needed.

    #| Initialize the overall MUGS client app
    method initialize() {
        callsame;
        Pop.new(:title('MUGS'));
    }

    #| Shut down the overall MUGS client app (as cleanly as possible)
    method shutdown() {
        Pop.stop;
        Pop.destroy;
        callsame;
    }

By default, the app can create a single game UI (in launch-game-ui), and delegates rendering to that game UI's main loop (in play-current-game). That will work fine for now, so commit the changes so far:

$ git add lib/MUGS/App/Pop.rakumod
$ gm "Initialize and shutdown Pop toolkit in App"
[main 48f4157] Initialize and shutdown Pop toolkit in App
 1 file changed, 5 insertions(+), 3 deletions(-)

Create a Test Game Plugin

As seen earlier, a new MUGS UI needs a game plugin in order to activate, so next up is stubbing one (for more details on this, see the other half of this doc):

$ mugs-tool new-game-ui PFX Pop --/genre --desc="particle effect test"

--> All commands executed successfully.

That creates a simple skeleton in lib/MUGS/UI/Pop/Game/PFX.rakumod:

# ABSTRACT: Pop interface for particle effect test games

use MUGS::Core;
use MUGS::Client::Game::PFX;
use MUGS::UI::Pop;


#| Pop interface for a particle effect test game
class MUGS::UI::Pop::Game::PFX is MUGS::UI::Pop::Game {
    method game-type() { 'pfx' }
}


# Register this class as a valid game UI
MUGS::UI::Pop::Game::PFX.register;

However, this skeleton is too skeletal to actually function:

$ raku -Ilib bin/mugs-pop pfx
Loading MUGS.
===SORRY!=== Error while compiling .../MUGS/MUGS-UI-Pop/bin/mugs-pop
No such method 'main-loop' for invocant of type
'MUGS::UI::Pop::Game::PFX'
at .../MUGS/MUGS-UI-Pop/bin/mugs-pop:4

Filling in simple functionality (again, the reasoning for all of this, and the details of what these new methods do will be described in the other half of this doc):

# ABSTRACT: Pop interface for particle effect test games

use Pop;

use MUGS::Core;
use MUGS::Client::Game::PFX;
use MUGS::UI::Pop;


#| Pop interface for a particle effect test game
class MUGS::UI::Pop::Game::PFX is MUGS::UI::Pop::Game {
    method game-type() { 'pfx' }

    method handle-server-message($message) {
        given $message.type {
            when 'game-update' {
                $.client.validate-and-save-update($message);
            }
            when 'game-event'  {
                self.handle-game-event($message);
            }
        }
    }

    method handle-game-event($message) {
    }

    method main-loop(::?CLASS:D:) {
        Pop.key-released: -> $key, $scancode {
            given $key {
                when 'q'|'ESCAPE' { await $.client.leave; Pop.stop }
                when 'SPACE'      { $.client.send-pause-request }
            }
        };

        Pop.render: { self.render-updates };

        Pop.run;
    }

    method render-updates() {
        my $update;
        $.client.update-lock.protect: {
            my $queue := $.client.update-queue;
            $queue.shift while $queue.elems > 1;
            $update = $queue[0];
        }

        # Don't show anything if no data to work with
        return unless $update;

        Pop::Graphics.clear;
        self.render-particles($update<validated>);
    }

    method render-particles($validated) {
        my num $w  = Pop::Graphics.width;
        my num $h  = Pop::Graphics.height;
        my num $cx = $w / 2e0;
        my num $cy = $h / 2e0;
        my num $r  = min $cx, $cy;

        for @($validated<effects>) -> $effect {
            my $p = $effect<particles>;
            for ^($p.elems div 7) -> int $index {
                my int $base = $index * 7;
                my num $x    = $p[$base];
                my num $y    = $p[$base + 1];

                # Scale, flip Y dimension, and recenter to current particle area
                my num $px =  $x * $r + $cx;
                my num $py = -$y * $r + $cy;

                Pop::Graphics.point: ($px, $py), (^256).pick xx 3;
            }
        }
    }
}


# Register this class as a valid game UI
MUGS::UI::Pop::Game::PFX.register;

Now running it Just Works:

$ raku -Ilib bin/mugs-pop pfx
Loading MUGS.

A blank window should appear with a (very) simple particle effect in it. The animation won't be silky smooth as there is no keyframe interpolation yet, and the particles are colored randomly so will appear to "twinkle", but as a proof of concept it's fine. Press the spacebar to toggle pause, or the Q or escape keys to quit. (You may see some debug messages when the process exits; those are coming from the underlying Pop toolkit.)

Creating a New Game Plugin for an Existing UI

Whether you have just created a new UI type as described in the first part of this doc, or are working with an existing UI type, at some point you'll want to add new game plugins for that UI.

This is the simpler case of course; the existing MUGS UI type and its app are already known to work for other games, limiting the scope of bug hunts. Furthemore, the existing game's client and server modules are already known to work for other UIs, assuming this isn't the very first UI being created for the game -- or if it is, the client and server plugins have at least already been tested to work together. It is only the combination of this UI with that game that isn't working yet.

Understand the Existing Code

Before starting to write the new plugin, take some time to understand the existing working code. Examine the existing client and server plugins to understand what actions the client can take, how they can fail, and what asynchronous updates are likely from the server.

Also take a look at other games already working in the UI. It's especially useful to examine plugins for similar games, especially if they share common genres with your game. Understand how games of those genres work in that UI.

Sketch Your Design

Take a few minutes to sketch out your UI flow and layout. What do you expect the game display to look like? How does the player choose their actions? How would the player use assistive technologies, such as a screen reader or alternate input device? How are errors and other failures handled?

You'll likely iterate the design over time, but investing a few minutes up front can reduce the "blank page problem" that makes it difficult to get started.

Stub the New UI Plugin

It's easiest to build a UI plugin when you have something that you can interact with at all times. To that end, the first actual coding task will be to stub in the new UI plugin, enough that the UI app recognizes it as a valid UI plugin and lets you join the proper game type with it. Even if the only thing it can do is put up a message that it's alive and display the GameID the client has joined, that's enough to start.

Let's start over with the PFX example used in the Creating a New UI Type section, except with more detail. Go ahead and move aside or delete the PFX module if you'd already made one, and then regenerate the skeleton using mugs-tool new-game-ui:

$ rm lib/MUGS/UI/Pop/Game/PFX.rakumod
$ mugs-tool new-game-ui PFX Pop --/genre --desc="particle effect test"

--> All commands executed successfully.

It's going to need access to Pop functionality, so load the required modules at the top:

use Pop;
use Pop::Graphics;

The game plugin won't function without a main-loop tailored to the UI toolkit, so stub one:

    method main-loop(::?CLASS:D:) {
        Pop.key-released: -> $key, $scancode {
            if $key eq 'ESCAPE' { await $.client.leave; Pop.stop }
        };

        Pop.render: { Pop::Graphics.clear };

        Pop.run;
    }

This is pretty much the minimum to set up a Pop app that behaves reasonably. It clears the window on each render opportunity, and listens for the escape key to know when to leave the app; otherwise it just lets the Pop-internal main loop run.

With the new method in place, running this plugin presents a blank window from which you can press ESCAPE to exit:

$ raku -Ilib bin/mugs-pop pfx
Loading MUGS.

Time to commit the stub and make sure everything's clean so far:

$ git add lib/MUGS/UI/Pop/Game/PFX.rakumod
$ gm "Stub in minimal PFX game plugin"
[main 6e48b1b] Stub in minimal PFX game plugin
 1 file changed, 28 insertions(+)
 create mode 100644 lib/MUGS/UI/Pop/Game/PFX.rakumod
$ git status
On branch main
nothing to commit, working tree clean

Listen to Server Messages

Most games that aren't solely turn-based solitaire games will need to listen for asynchronous server messages, such as push updates and game-wide events. To listen for these, add a simple handle-server-message method:

    method handle-server-message($message) {
        given $message.type {
            when 'game-update' {
                $.client.validate-and-save-update($message);
            }
            when 'game-event'  {
                self.handle-game-event($message);
            }
        }
    }

    method handle-game-event($message) {
    }

In this case, game-update messages are passed back to the Client layer -- via the $.client attribute that all game UIs inherit -- for validation and queuing, while game-wide events currently do nothing.

Display Initial Game State

Write enough display logic to show the initial game state, at least at a very basic level. For example, you might display a checkerboard with pieces in the appropriate positions for Chess or Draughts, or display the player's hand for a card game.

For the PFX example, all of the particle effect data is being passed in the game-update messages that are now being queued in the Client layer; the next step is to add a render-updates method to pull the latest update from the queue and render it:

    method render-updates() {
        my $update;
        $.client.update-lock.protect: {
            my $queue := $.client.update-queue;
            $queue.shift while $queue.elems > 1;
            $update = $queue[0];
        }

        # Don't show anything if no data to work with
        return unless $update;

        Pop::Graphics.clear;
        self.render-particles($update<validated>);
    }

The first part simply makes sure that we don't get thread races while modifying the queue, dropping any update except the most recent. The last part clears the window and passes the validated update data to render-particles, filled in next:

    method render-particles($validated) {
        my num $w  = Pop::Graphics.width;
        my num $h  = Pop::Graphics.height;
        my num $cx = $w / 2e0;
        my num $cy = $h / 2e0;
        my num $r  = min $cx, $cy;

        for @($validated<effects>) -> $effect {
            my $p = $effect<particles>;
            for ^($p.elems div 7) -> int $index {
                my int $base = $index * 7;
                my num $x    = $p[$base];
                my num $y    = $p[$base + 1];

                # Scale, flip Y dimension, and recenter to current particle area
                my num $px =  $x * $r + $cx;
                my num $py = -$y * $r + $cy;

                Pop::Graphics.point: ($px, $py), (^256).pick xx 3;
            }
        }
    }

The first part of render-particles finds the center of the Pop::Graphics area, and thus the scaling "radius" to use when drawing particles. The second part loops over all effects in the update, and for each particle in each effect pulls location information from the packed dataset, converts the virtual coordinates to Pop::Graphics coordinates, and draws a randomly-colored point there.

In order to see the effects of these additions, update the render callback in the main-loop method:

        Pop.render: { self.render-updates };

Running this using mugs-pop pfx again shows the particle effect animation, but still the player can't do anything beyond quit.

Hook Up a Trivial Action

Add enough functionality to do something trivial in the game, even if it's ugly and hardcoded. "Push this button to play the first move in the center" is perfectly acceptable. At this point, you're just making sure you can wire up the UI's input to trigger an action that you send through the existing client to the server, are able to process the server's response, and can display the changed game state.

In the PFX case, the Client layer already knows how to toggle pause with a server request, so that's first up. Thankfully, this is as easy as expanding the key-released callback in main-loop:

        Pop.key-released: -> $key, $scancode {
            given $key {
                when 'q'|'ESCAPE' { await $.client.leave; Pop.stop }
                when 'SPACE'      { $.client.send-pause-request }
            }
        };

At the same time, I've added the q key as an alternative to the escape key to exit; it's trivial to check both at the same time. One last run with mugs-pop pfx shows that both new keys work as intended.

Lather, Rinse, Repeat

At this point, you should be able to start up a loop of hooking up additional actions, followed by cleaning up, generalizing, or refactoring your existing code, before going back and adding more. Don't forget to test alternate input methods (keyboard instead of mouse, for instance) and output modalities (with a screen reader or without audio, for instance).

Polish and Publish

Do a final pass over your new plugin, especially with an eye towards the MUGS coding standards. When you're more or less satisfied with the current state, it's time to publish your new plugin:

  • If the existing game and UI are already part of the MUGS core repositories, submit your new plugin as a PR to the appropriate UI repository, such as MUGS-UI-CLI for a new CLI plugin.

  • If the existing game is not part of the MUGS core but the UI type is, you can offer your plugin as a separate module uploaded to zef/fez or CPAN. Make sure to depend on both the MUGS UI module and the game implementation in your META6 file.

  • If the UI type is not part of the MUGS core, consider submitting your new plugin to the owners of that UI repository; you can also offer it as a separate module, just as in the case where the game itself is not in the MUGS core.

Sing, Rejoice, and Announce

That's it! Now go tell #mugs on Libera.Chat IRC about your new plugin. :-)

MUGS v0.1.4

Multi-User Gaming Services - A Raku-based platform for game service development

Authors

  • Geoffrey Broadwell

License

Artistic-2.0

Dependencies

MUGS::Core:ver<0.1.4>MUGS::Games:ver<0.1.4>MUGS::UI::CLI:ver<0.1.4>MUGS::UI::TUI:ver<0.1.4>MUGS::UI::WebSimple:ver<0.1.4>

Test Dependencies

Provides

  • MUGS

The Camelia image is copyright 2009 by Larry Wall. "Raku" is trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.