port-a-solo-game

% Porting a Solo Game to MUGS

WIP as of 2021-05-26

Already have a solo (single-player) game implemented that you'd like to port to MUGS? Great! This guide should help you get started.

As concrete examples, we'll port a couple Raku/Pop games from pop-games, themselves based on the Lua/LÖVE and Python/Pygame Zero tutorials at Simple Game Tutorials, starting with Blackjack.

Step 0: TEST YOUR MUGS INSTALL!

If you haven't already done so, follow the MUGS Install Guide to get MUGS installed properly and test that you can:

  • Play a couple simple text games with mugs-cli

  • Create a persistent identity universe with mugs-admin create-universe

  • Start a WebSocket server with mugs-ws-server

  • Connect to that server with mugs-cli or mugs-web-simple

  • Create MUGS identities to test with

If you haven't done these steps first, the following sections will likely be quite confusing, and you may find yourself very frustrated if it turns out MUGS isn't working correctly on your system.

Step 1: Refactoring

Before doing the actual MUGS port, it's useful to begin with some initial refactoring within your already-working game. This will reduce the porting difficulty, while allowing you to continuously test the working game to make sure nothing gets lost in the refactoring work.

Assess Original Code

The biggest change that porting will impose isn't actually particular to the MUGS APIs, but rather to its basic architecture. Many "personal project" games are designed for a single player, or perhaps a couple players sharing a single computer. This often results in code that mixes different responsibilities, or makes simplifying assumptions based on synchronous local play, simply because there was no strong reason not to. For example, an adventure game might only simulate the creatures currently visible to the single player instead of across the whole game map; an action game might detect collisions not by analytically intersecting bounding hulls but by checking whether UI sprites overlap on the screen at rendering time; a game UI might assume that it has total knowledge of the entire game state at all times and that state changes are strictly ordered.

In contrast, MUGS is thoroughly client/server and asynchronous, strictly separating server-managed game state from the view of any individual player. Similarly, MUGS separates the game client and game UI. A game client packages requests and messages to be sent to the server, validates responses and push updates from the server, and tracks locally-cached data. A game UI handles user input and renders an individual's view; it's strictly a player I/O function.

The first task is thus looking through the existing game code to see what will need to be disentangled to fit that overall architecture. Browsing through the original Pop Blackjack program at the time of writing, we can see tightly woven game state and UI code (and since it was written for local-only play, no concepts of client and server):

UI?LinesPurpose
3-4Load Pop modules
6-7Pop UI boilerplate
9-10Card sizes in UI
11-12Define ranks and suits
14Sprite for card back
15-17Game state
19Define Win/Lose/Draw enum
21Load card textures
~22Shuffle deck, deal hands, set up UI
24-32Define a UI button
~34-39Define a Card
41-73Define a Player, taking a card, and scoring a hand
75-105Register key, mouse, and expose event UI callbacks
108-114Determine if player hand wins or loses automatically
116Fill dealer hand
118-125Determine if player wins or loses against dealer
127-182Render current game state in UI
184Start Pop main loop
~186-197Define hit and stand actions
199-200Reset UI before shuffling
~202-209Define cards and card textures
211-242Set visible UI buttons
~244-245Deal initial cards and set player-done UI callback
247Detect instant win condition
249Force dirty flag True
252-303Load simple textures/sprites
305-397Generate procedural card textures/sprites

The code also only handles a single player and a single deck of cards. We can break those assumptions later; for now, we'll focus just on separation of concerns.

Reordering Existing Code

The most basic refactoring task is simply reordering the existing code to bring UI code together, separate from non-UI code where possible. I've reordered the code to place shared or non-UI code toward the top, and pure UI code toward the bottom:

UI?Old LinesNew LinesPurpose
3Add section header comment
195Define Win/Lose/Draw enum
11-127-8Define ranks and suits
15-1710-12Game state
41-7314-46Define a Player, taking a card, and scoring a hand
~186-19748-59Define hit and stand actions
~34-3961-66Define a Card
69Add section header comment
3-472-73Load Pop modules
6-775-76Pop UI boilerplate
9-1078-79Card sizes in UI
1480Sprite for card back
24-3282-90Define a UI button
2192Load card textures
~2293Shuffle deck, deal hands, set up UI
75-10595-125Register key, mouse, and expose event UI callbacks
108-114127-134Determine if player hand wins or loses automatically
116136Fill dealer hand
118-125138-145Determine if player wins or loses against dealer
127-182146-202Render current game state in UI
184204Start Pop main loop
199-200206-207Reset UI before shuffling
~202-209209-216Define cards and card textures
~244-245218-219Deal initial cards and set player-done UI callback
247221Detect instant win condition
249Force dirty flag True (removed, redundant)
211-242224-255Set visible UI buttons (moved out one scope level)
252-303257-308Load simple textures/sprites
305-397310-402Generate procedural card textures/sprites

(The program is slightly longer now because I added section header comments.)

Note that mere cut-and-paste is not enough to completely separate the concerns. For example, determining player win/loss and filling the dealer hand are both found in the Pop.update() UI callback, and the reset routine still mixes card sprite management, button handling, initial deal, and checking for instant-win; these will all need to be disentangled.

Separate Game Core From UI

Now that the program is grouped into rough sections, it's time to do the more detailed disentangling that requires editing individual class and routine definitions.

Disentangling Card

Here's the original definition for the Card class:

class Card {
    has Str $.rank is required;
    has Str $.suit is required;
    has     $.sprite;
    method WHICH { "$!suit:$.rank" }
}

This is almost a pure game core class, except that it's tracking the UI sprite for each card. Since Card provides a definitive WHICH that can be used to uniquely identify every card with a convenient string, we can instead separate out the sprite lookup into a hash of its own:

class Card {
    has Str $.rank is required;
    has Str $.suit is required;
    method WHICH { "$!suit:$.rank" }
}

my  %CARD-SPRITE;        # Sprites for each card face

Of course, this requires fixing the dealer and player card render lines so that instead of pulling the sprite from the Card:

        Pop::Graphics.draw: $c.sprite, ( $x, $y );

they instead pull the sprite from the lookup hash:

        Pop::Graphics.draw: %CARD-SPRITE{$c.WHICH}, ( $x, $y );

The %CARD-SPRITE lookup hash can be filled at UI startup when the card face sprites are generated; the end of sub make-suit changes from:

    for RANKS.kv -> $index, $rank {
        $texture.make-sprite: $rank,
            x => $index * CARD-WIDTH, y => 0, w => CARD-WIDTH, h => CARD-HEIGHT;
    }

to:

    for RANKS.kv -> $index, $rank {
        %CARD-SPRITE{"$suit:$rank"} = $texture.make-sprite: $rank,
            x => $index * CARD-WIDTH, y => 0, w => CARD-WIDTH, h => CARD-HEIGHT;
    }

The above changes then unlock a partial cleanup of sub reset -- when setting up the deck, all of the texture and sprite bits can be removed to change this:

    $DECK .= new;
    for SUITS -> $suit {
        my $texture = Pop::Textures.get: $suit;
        for RANKS -> $rank {
            $DECK.set: Card.new: :$suit, :$rank,
                sprite => $texture.get-sprite($rank);
        }
    }

to this:

    $DECK .= new;
    for SUITS -> $suit {
        for RANKS -> $rank {
            $DECK.set: Card.new: :$suit, :$rank;
        }
    }

Disentangling sub reset

With the above sprite cleanup, sub reset only has two remaining bits of UI code, the calls to set-buttons. Here's the current routine:

sub reset {
    set-buttons 'in-round';

    $DECK .= new;
    for SUITS -> $suit {
        for RANKS -> $rank {
            $DECK.set: Card.new: :$suit, :$rank;
        }
    }

    .take: 2 with $player = Player.new: on-done => { set-buttons 'next-round' }
    .take: 2 with $dealer = Player.new;

    $player.end-turn if $player.value == 21;
}

The difficult call to move is the second one, since it's part of the constructor arguments for $player, setting up the on-done callback, which can only be set once in new because of these lines in class Player:

    has Promise $.done .= new; # Kept once end-turn has been called

    submethod TWEAK (:&on-done) { $!done.then: &on-done }

The idea here is to allow additional code blocks to run whenever the original promise completes. Let's replace that TWEAK with a post-construction method:

    method on-done(&cb) { $!done.then: &cb }

Then replace the final lines of sub reset with:

    .take: 2 with $player = Player.new;
    .take: 2 with $dealer = Player.new;

    $player.on-done: { set-buttons 'next-round' };
    $player.end-turn if $player.value == 21;

This allows factoring shuffle-and-deal out of reset:

sub shuffle-and-deal {
    $DECK .= new;
    for SUITS -> $suit {
        for RANKS -> $rank {
            $DECK.set: Card.new: :$suit, :$rank;
        }
    }

    .take: 2 with $player = Player.new;
    .take: 2 with $dealer = Player.new;
}

sub reset {
    shuffle-and-deal;

    set-buttons 'in-round';
    $player.on-done: { set-buttons 'next-round' };
    $player.end-turn if $player.value == 21;
}

Since shuffle-and-deal is pure core game code, it can move up to the non-UI section, right under the definition of class Card.

This works and is certainly a significant cleanup, but we can go even further by explicitly calling shuffle-and-deal separately from reset (which can be renamed to reset-ui while we're at it). Game startup changes from:

make-deck-textures; # Card textures are generated on load
reset;              # Reset repopulates the deck and deals initial hands

to:

make-deck-textures; # Card textures are generated on load
shuffle-and-deal;   # Repopulates the deck and deals initial hands
reset-ui;           # Resets UI state for a new game

The 'SPACE' key press action changes to:

    when 'SPACE' {
        shuffle-and-deal;  # New game
        reset-ui;
    }

And the action for the 'Play again' button changes to:

            action => { shuffle-and-deal; reset-ui },

reset-ui is now just:

sub reset-ui {
    set-buttons 'in-round';
    $player.on-done: { set-buttons 'next-round' };
    $player.end-turn if $player.value == 21;
}

(We'll deal with that $player.end-turn call in a later section.)

Making $dirty UI-only

The $dirty global flag is declared near the top in the shared code, simply because player-hit and player-stand both set it. But it's really for the benefit of the UI as an optimization to prevent constantly repeating rendering. As far as the core game is concerned, any action at all requires updating the player, but it's not in charge of that.

The first step is to move the declaration of $dirty down to the UI declaration section, where the sprite globals are declared.

Then remove the flag setting from player-hit and player-stand, leaving just:

sub player-hit {
    return if $player.done;
    $player.take;
    $player.end-turn if $player.value >= 21;
}

sub player-stand {
    return if $player.done;
    $player.end-turn;
}

The callbacks now need to be updated to explicitly set the flag directly. First key-pressed:

Pop.key-pressed: -> $_, $, $ {
    Pop.stop when 'ESCAPE';

    when not $$player.done {
        when 'h' { player-hit;   $dirty = True }
        when 's' { player-stand; $dirty = True }
    }
    when 'SPACE' {
        reset; # New game
    }
}

Then the in-round button callbacks in set-buttons:

    when 'in-round' {
        Pop::Entities.create: Button.new(
            text => 'Hit!',
            text-pos => Pop::Point.new(22, 238 ),
            box => Pop::Rect.new( 10, 230, 53, 25 ),
            action => { player-hit; $dirty = True },
        );

        Pop::Entities.create: Button.new(
            text => 'Stand',
            text-pos => Pop::Point.new( 79, 238 ),
            box => Pop::Rect.new( 73, 230, 53, 25 ),
            action => { player-stand; $dirty = True },
        );

        $dirty = True;
    }

Reassess

Here's what the code layout looks like now:

UI?Old LinesNew LinesPurpose
33Section header comment
55Define Win/Lose/Draw enum
7-87-8Define ranks and suits
10-1210-11Game state
14-4613-45Define a Player, taking a card, and scoring a hand
48-5947-56Define hit and stand actions
61-6658-62Define a Card
64-74Shuffle and deal a new game (factored out of reset)
6977Section header comment
72-7379-80Load Pop modules
75-7682-83Pop UI boilerplate
78-7985-86Card sizes in UI
8087Sprite for card back
88Card face sprite lookup (factored out of Card)
89Dirty flag (factored to only UI)
82-9091-99Define a UI button
92101Load card textures
218-219102Trigger shuffle deck/deal hands (split out explicitly)
93103Reset UI (split out explicitly)
95-125105-136Register key, mouse, and expose event UI callbacks
127-134138-145Determine if player hand wins or loses automatically
136147Fill dealer hand
138-145149-156Determine if player wins or loses against dealer
146-202158-213Render current game state in UI
204215Start Pop main loop
X209-216Define cards and card textures (split up)
206-207218Reset button UI
219Set player-done UI callback (split out)
221220Detect instant win condition
224-255223-254Set visible UI buttons
257-308256-307Load simple textures/sprites
310-402309-401Generate procedural card textures/sprites

The big remaining bits to pull out of the UI code are the triggers for dealing cards and determination of win or loss.

Disentangling update

First off, there are two places outside update that are doing things update ought to be doing. In particular player-hit and reset-ui both check if the player's turn is instantly over, but since update runs during every Pop main loop iteration, we can consolidate them. Here are the previous versions:

sub player-hit {
    return if $player.done;
    $player.take;
    $player.end-turn if $player.value >= 21;
}

sub reset-ui {
    set-buttons 'in-round';
    $player.on-done: { set-buttons 'next-round' };
    $player.end-turn if $player.value == 21;
}

Remove the last line of each, leaving:

sub player-hit {
    return if $player.done;
    $player.take;
}

sub reset-ui {
    set-buttons 'in-round';
    $player.on-done: { set-buttons 'next-round' };
}

Add the first of those to the very top of update, with a check for !$player.done in order to prevent multiple $player.end-turn invocations, and stop checking for $dirty before $player.done:

Pop.update: {
    $player.end-turn if !$player.done && $player.value >= 21;
    next unless $player.done && !$dealer.done;

Note that the copied line uses the more general >= comparison, and it must happen before the next line checks $player.done.

At this point the Pop.update block has no references to the UI at all, except that it exits early with next rather than return. By making that change, we can split it out to its own routine:

sub maybe-finish-hand {
    $player.end-turn if !$player.done && $player.value >= 21;
    return unless $player.done && !$dealer.done;

    my $player-value = $player.value;
    $dealer.win  when $player-value >  21;
    $dealer.lose when $player-value == 21;

    return if $dealer.done;

    $dealer.take while $dealer.value < 17;

    my $dealer-value = $dealer.value;
    $dealer.draw when $player-value == $dealer-value;
    $dealer.win  when $player-value <  $dealer-value <= 21;

    return if $dealer.done;

    $dealer.lose;
}

Pop.update: { maybe-finish-hand }

Of course, maybe-finish-hand can now be moved up to the non-UI code section, completing the separation of concerns:

UI?Old LinesNew LinesPurpose
33Section header comment
55Define Win/Lose/Draw enum
7-87-8Define ranks and suits
10-1110-11Game state
13-4513-45Define a Player, taking a card, and scoring a hand
47-5647-55Define hit and stand actions
58-6257-61Define a Card
64-7463-73Shuffle and deal a new game
22075-76Detect instant turn over condition
138-14577-83Determine if player hand wins or loses automatically
14785Fill dealer hand
149-15687-94Determine if player wins or loses against dealer
7797Section header comment
79-8099-100Load Pop modules
82-83102-103Pop UI boilerplate
85-86105-106Card sizes in UI
87107Sprite for card back
88108Card face sprite lookup
89109Dirty flag
91-99111-119Define a UI button
101121Load card textures
102122Trigger shuffle deck/deal hands
103123Reset UI
105-136125-156Register key, mouse, and expose event UI callbacks
158Register update callback
158-213160-215Render current game state in UI
215217Start Pop main loop
218219Reset button UI
219220Set player-done UI callback
223-254224-255Set visible UI buttons
256-307257-308Load simple textures/sprites
309-401310-402Generate procedural card textures/sprites

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.