create-a-ui
% Creating a New MUGS UI
Reviewed 2021-05-21 by japhb
There are actually two common cases here:
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
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 dependenciesAlmost 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 outputIt 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:4Filling 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 cleanListen 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
META6file.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. :-)