Close

Writing a 2048 game for a ZeroPhone

A project log for ZeroPhone - a Raspberry Pi smartphone

Pi Zero-based open-source mobile phone (that you can assemble for 50$ in parts)

ArsenijsArsenijs 12/10/2017 at 04:210 Comments

Today, I was watching my wife play 2048, and I remembered about all the fun I had with this game myself, back when it was popular. While I don't recall ever getting past 512, I did spend a lot of time and I liked it. So, ZeroPhone can fit 8 lines of text on the screen, and 2048 needs a 4x4 playing field - seems like a perfect match!

I found some Python code on GitHub that seems to be a great fit - it's cleanly split into logic and GUI parts, and logic does not depend on the GUI in any way, so can be re-used easily. Now that's a great design, if you ask me =) Reading further into it, it seems to be somebody's programming assignment, and I have to say that it was very helpful of that person to put it on GitHub!

So, let's reuse the logic part, and build our own UI for the game!

---------- more ----------

I could design the game view to be pretty, however, I'd rather make it work first and then go from there. ZPUI doesn't yet provide a lot of facilities to draw pretty UIs on the screen - which is mostly because the move from text-based UI is still ongoing, and I still need to invent a way of coding pretty interfaces that would look great, both on the display and in the code. So, the main problem with lack of pretty UI-making facilities is the fact that I want to make a quick prototype now, and until the facilities are there, it's either "quick" or "pretty" =D

For a start, let's include the logic.py file from the repo. It's MIT-licensed, and ZPUI is licensed under Apache 2.0 - so, let's provide attribution! (commit)

logic.py is an assembly of functions that work on a matrix of numbers (the game field), and each game would have its own matrix. Let's refactor it into an object! (commit)

That took quite a while. In the end, it seems like there won't be anything left from the original code =) Now, let's convert the UI into a ZeroPhone app! 

In fact, I don't even need to look at the original UI - 2048 is simple enough of a game so that I can almost instantly understand what's going on. There's a 4x4 field of numbers (basically, a two-dimensional array), four functions that can be straightforwardly attached as key callbacks ("left", "right", "up" and "down"), as well as one function that determines whether the game has ended.


One hour later... Here's the resulting code, let's go through it! First, the imports:

from threading import Event, Lock
from time import sleep

from apps import ZeroApp
from ui import DialogBox, ffs
from helpers import ExitHelper

from logic import GameOf2048

Recently, ZPUI got a new feature - class-based apps. If your app's architecture looks like it'd be best expressed as a class, this is exactly what you need! In our case, this object would be the game app, taking care of managing games and UI:

class GameApp(ZeroApp):

    game = None
    do_exit = None
    is_processing = None
    menu_name = "2048"

    def on_start(self):
        #A flag to tell when the user wants to exit the app
        self.do_exit = Event()
        #A lock to make sure that user's keypresses don't overlay
        self.moving = Lock()
        if self.game is None:
            #No game started yet, starting
            self.start_new_game()
        elif self.game.get_game_state() == 'lose':
            #The last game was lost and abandoned
            #But maybe, just maybe, the user wants to show somebody the result!
            start_new = DialogBox("ync", self.i, self.o, message="Last game lost, start new?").activate()
            if start_new is None:
                return # Picked cancel, exiting the app - user doesn't want to resume playing
            elif start_new is True:
                self.start_new_game() #Discarding the old game and starting a new one
            #If pressed No, we don't start a new game, but don't exit either
        #By now, the `game` property should have a game
        #Let's launch the main loop
        while not self.do_exit.isSet():
            self.game_loop()

    def start_new_game(self):
        #A simple alias
        self.game = GameOf2048(4, 4)

Now we can bind some actions to user's keypresses:

    def set_keymap(self):
        keymap = {"KEY_LEFT": lambda:self.make_a_move("left"),
                  "KEY_RIGHT": lambda:self.make_a_move("right"),
                  "KEY_UP": lambda:self.make_a_move("up"),
                  "KEY_DOWN": lambda:self.make_a_move("down"),
                  "KEY_ENTER": self.confirm_exit}
        self.i.stop_listen()
        self.i.set_keymap(keymap)
        self.i.listen()

    def confirm_exit(self):
        #If the user has pressed OK, we can't just exit the game straight away
        #It might have been an accident!
        #But first, acquiring the lock - so that two keypresses
        #won't ever be processed simultaneously, for whatever reason.
        with self.moving:
            #If game is over, the user is more likely to exit than if the game is still active
            #So let's be a little more user-friendly
            ordering = "ny" if self.game.get_game_state() == 'not over' else "yn"
            do_exit = DialogBox(ordering, self.i, self.o, message="Exit the game?").activate()
            if do_exit:
                self.do_exit.set()
            else:
                #User didn't want to exit the game
                #So, after DialogBox has disappeared from the screen, we need to re-set the keymap
                #and put the game back on the screen
                self.set_keymap()
                self.refresh()

    def make_a_move(self, direction):
        with self.moving:
            #With an argument, we don't need to write four functions, just one
            #And if we need to, we have one central place to debug things in
            assert(direction in ["up", "down", "left", "right"])
            getattr(self.game, direction)()
            self.refresh()

Then, the game's main loop. It takes care of 3 things - keeping track of when game ends, waiting on the game's end screen, then asking the user whether they want to continue the game (or maybe they're bored and want to quit altogether).

    def game_loop(self):
        #In the beginning, need to do this - so that we get the initial game screen and keys respond to actions
        self.set_keymap()
        self.refresh()
        while self.game.get_game_state() == 'not over' and not self.do_exit.isSet():
            #Here, we're simply waiting until the game ends
            #All the fun is happening in another thread, where input callbacks are processed
            sleep(1)
        #We might have two reasons for exiting the "waiting loop":
        #one of them is user quitting the game
        if self.do_exit.isSet():
            return
        #User hasn't prompted for exit, that means the game has ended
        #Waiting for player to click any of five primary keys
        #Then, prompting to restart the game
        eh = ExitHelper(self.i, keys=self.i.reserved_keys).start()
        while eh.do_run():
            sleep(0.1)
        #At this point, the user has pressed some key, so let's ask them what they want to do next
        do_restart = DialogBox("ync", self.i, self.o, message="Restart the game?").activate()
        if do_restart is None: #Cancel - leaving the playing field as-is
            return
            #After this function returns, it'll be launched again
            #And the user will return to the playing field, with the "You won/lost" text
        elif do_restart is False: #No - not restarting, so, exiting the game
            self.do_exit.set()
        else:
            self.start_new_game() #Yes - restarting (game_loop will be entered once more from on_start() )

One important thing about class-based apps - you write a class, and then ZPUI creates an object from that class. That object persists from ZPUI boot until ZPUI exit, so you get persistence for free - this allows us to simply continue the game that was started when the user exits the game, uses some other app and then comes back. However, you need to remember that your object's "on_start()" can be called more than once (just like "callback()" in simpler apps can be). 


Earlier, when ZPUI was still pyLCI and worked exclusively with character displays, it only worked with text. Nowadays, it can still output text data on the screen, in a 8x6 font - fitting 8 lines of text, 21 character in each. In our case, it's more than enough for a quick prototype that will, nevertheless, look good and work well enough to be shipped with ZPUI. 

To generate a game UI, we're using lots of Python string functions and a little bit of ASCII art (if using dots as placeholders can be called "ASCII art"):

    def display_field(self, field):
        #These asserts act more like warnings to tell that this function 
        #hasn't been tested with playing fields other than 4x4
        assert len(field) == 4, "Can't display a field that's not 4x4!"
        assert len(field[0]) == 4, "Can't display a field that's not 4x4!"
        #Constructing an array of strings to be sent to the display
        display_data = []
        #We need to designate some space for each cell of the playing field
        #So that the numbers don't move when they increase and move around
        #With 21 character, each cell will be 5 characters wide - more than OK
        space_for_each_number = self.o.cols / len(field[0])
        for field_row in field:
            #A dot is a good placeholder, it's small but it's there
            #And we can't draw lines on the screen from here anyway
            field_row_str = [str(i) if i else "." for i in field_row]
            #Now, let's actually build a row of the playing field
            display_row = "".join(str(i).center(space_for_each_number) for i in field_row_str)
            #I guess it could've been more readable.
            display_data.append(display_row.ljust(self.o.cols))
            #Adding a "spacer" - just an empty row
            display_data.append(" "*self.o.cols)
        #Now we have 8 rows, but we need to overlay some more text
        #Replacing the center row with the game state, if we need to show it:
        game_state = self.game.get_game_state()
        #Creating a dictionary and getting an item from it in one go
        state_str = {"win":"You won!",
                     "lose":"You lost!",
                     "not over":""  }[game_state]
        display_data[3] = state_str.center(self.o.cols)
        #Let's add the game name in the footer
        #No real need to do it, but why not
        display_data[7] = "2048".center(self.o.cols)
        return display_data

    def refresh(self):
        #A simple function that draws current state of the game on the screen
        displayed_field = self.display_field(self.game.get_field())
        self.o.display_data(*displayed_field)

Now, a quick collage from screenshots I made during a test game:


As a result of this, there's the first, simple game for ZeroPhones, and it's already in the latest ZPUI code! You, too, can help - here's a GitHub issue describing what features it'd be great to add to this game. This GH issue is not even about this particular game, it's more about writing code that other people can then re-use as examples of implementing this particular feature - that's why your input here would be useful for the ZeroPhone project.

There's only one problem that I see now - the game seems too simple! This means that there might be some constraint that I'm missing. However, there are more important tasks for ZPUI at the moment, so I'll limit myself to what I've already done, for the time being =)

As a conculsion, I'd like to thank the ZeroPhone project's contributor, monsieur_h, for implementing the "class-based apps" feature in ZPUI. This feature is something that I wouldn't have paid attention to myself, but it's definitely a feature I'm going to use, having tried it now!

Discussions