cancel
Showing results for 
Search instead for 
Did you mean: 
mhopus

Top-down 2D game engine proof of concept

During the Christmas holidays in late 2022, I got an inspiration to create a game with PowerApps. For background, I've always had a soft spot for Sierra's and LucasArt's adventure games from the late 80s to early 90s. These games had wonderful elements that have stuck with me. In the past I've experimented writing games with various "non-game" platforms: a very simple civilization-type game with Powershell, a little Visual Basic (for Applications) game in Excel and even a small ASCII graphics maze puzzle with Intersystem's Caché language.

 

To give credit, while googling for ideas on what type of games have been made so far with PowerApps, one article from 2019 stood out and resonated with me: Making A Game in PowerApps by Chris Kent. I won't go into detail on what he did, so check that link out. I also want to give a standing ovation to https://opengameart.org/ that I used for most of the graphics in my game. Also, the tile set I'm using is painstakinly copied from game engineer HeartBeast's video https://www.youtube.com/watch?v=fCpalUPlhMs

 

loppu.gif

 

Instead of making a tutorial on how to build a game, I'm highlighting different functionalities I was able to implement in it.

 

 

THE MAP VS. HOW "ROOMS" ARE DRAWN.


This game is designed for rooms up to 15 tiles in width and 10 tiles in height. There is no scrolling in this game and as player moves over the edge of one "room", the adjacent room is loaded and drawn (I did actually make a scrolling screen engine too but is was too slow and I never figured where the bottleneck was).  Map for each room is a one-dimensional array such as this where there are 15 x 10 = 150 items (= tiles). "q0" means black space, "w1" one type of wall etc:

 

 

 

 

 

map2:[
"q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "q1"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "w4"; "f3"; "f3"; "f2"; "f3"; "f3"; "f1"; "f3"; "f3"; "f2"; "f3"; "f3"; "f3"; "f3"; "f2"; "f3"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "w2"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q5"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"; "q0"
];

 

 

 

 

 

While the map is one-dimensional, it makes more sense when it is visually split every 15th item. Now it looks like a 15 x 10 ascii map:

 

 

 

 

 

map2:[
"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";
"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";
"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";
"q1";"q1";"q1";"q1";"q1";"q1";"q1";"q1";"q1";"q1";"q1";"q1";"q1";"q1";"q1";
"w4";"w4";"w4";"w4";"w4";"w4";"w4";"w4";"w4";"w4";"w4";"w4";"w4";"w4";"w4";
"f3";"f3";"f2";"f3";"f3";"f1";"f3";"f3";"f2";"f3";"f3";"f3";"f3";"f2";"f3";
"w2";"w2";"w2";"w2";"w2";"w2";"w2";"w2";"w2";"w2";"w2";"w2";"w2";"w2";"w2";
"q5";"q5";"q5";"q5";"q5";"q5";"q5";"q5";"q5";"q5";"q5";"q5";"q5";"q5";"q5";
"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";
"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0";"q0"
];

 

 

 

 

 

rowscolumns.gifTechnically, the "board", the map window is built with two galleries. Gallery 1 is vertical and has 10 items (= rows). In each item of gallery 1 there is a horizontal nested gallery 2 that has 15 items (= columns).

 

Each item in Gallery 1 knows what it's "Y" is: 1, 2, 3, 4, 5, 6, 7, 8, 9 or 10. Each Nested Gallery 2 item knows what it's "X" is: 1 to 15. Additionally nested gallery 2's items know what their parent gallery item's "Y" value is so this way every single "tile" in these galleries knows it's X and Y. This is how each Gallery 2 item, when drawn, knows what map tile is must display, a wall, a floor or something else.

 

All and all each item in nested Gallery 2 has five or six objects layered on top of each other. The image that displays the tile (floor, wall) is at the bottom of the drawing hierarchy. Then there is another image that displays furniture, treasures etc if available. These are often PNG to use transparency. Then there are shadows that the walls cast, those are a layer of their own and finally, the fog of war which I'll explain later.

 

 

THE TIMERS, THE TRIGGERS AND THE TICK


tick.gifTo control the game mechanics, the animations, the delays, so that everything happens in an order, I created a master timer which we can call "the tick". It resets every .1 seconds and checks whats going on. The tick checks for example

  • if the character is in motion and should be redrawn
  • if there is dialogue going on that should be displayed
  • if something else is going on

There are additional timers that are used when necessary to control other things such as fading out and fading back in when chancing from one room to another. 

 

Each room has "exits", specific coordinates that align with doors and when the player reaches those coordinates, that trigger the engine to fade out, load a new room and fade in. The speed of the fade is controlled by an additional timer so that the fade looks natural.

.

.

.

.

.

MOVE BY POINTING AND CLICKING


demo1.gifAs mentioned earlier, each tile is a gallery item that has properties such as an X and Y coordinates and if it is a floor tile, walkable etc. When a gallery item is clicked and if it is indeed a walkable tile, that item's X and Y become the target for the player's character.

 

Then an animation phase begins where the character's X and Y are moved towards the target tile's X and Y incrementally. Let's say that the character is in tile x2,y2 and is moving towards x2,y5 (going "south" on screen). The character's path is 2,2 -> 2,3 -> 2,4 -> 2,5. Whenever the character arrives to a new tile, the game re-checks that are we at the target X and Y yet. If not, walking continues towads the next target tile. The character "moves" as it is re-drawn at it's new location every n'th of a second. When the character's X and Y match the target tile's X and Y, we are done and the animation phase ends. 

 

The target cursor, or Location/map icon you see on the game screen is just an icon that has a default alpha of "0". So the icon is present in every single tile, but invisible most of the time. However the HoverColor value of the icon is set to white with alpha "1". That way,  whichever gallery item the mouse is hovering over, the icon in that item displays itself.

 

The effect of the character walking is created by switching between four different images, each showing a different part of a step. A "step counter" keeps track which part of the step should be drawn. Later on I figured that I could just have hade one image of a character standing still and another animated gif of the character walking. And if the characteris walking, show the animated gif and if the character is standing still, show the static image.

 

 

"HOW DO I GET THERE - EXACTLY?"


path.gifI was able to implement a simple path-finding algorithm . When a target tile is clicked anywhere on the map, a step-by-step path is calculated from the goal back to the character. Once the path -like a treasure map- is calculated, then walking mode is activated. Walking mode means that player's X and Y are moved in small increments towards the next goal, the nearest next tile.

 

For example in the animation on left the character starts at x5,y4 and the target tile is at x8,y4. But there are wall and chairs and barrels on the path. The algorithm recognizes all pieces on the map that cannot be walked through.  What the algorithm does is start from the end, x8y4 and assings a value of "1"to surrounding tiles that are walkable. The

algorithm iterarates and assigns an incrmentia value of "2" to all neighbours of those with value "1", and the again "3" for all walkable neighbours of "2"s. This way after several iterations we finally either reach the character's tile - or we don't which means that the path is not reachable by the character. 

 

pahtvisualiser.gifOn the right I hand-drew a visualization of the order in which the path from the door to the other side of the wall is tracked. Ignore the character that stands idle next to the chair, it's not relevant for this example. Basically when a tile is clicked, a flood fill of incremential numbers is happens and it continues until either all spaces of the map are given a value or the character's X and Y have been reached. Then we can just go from "12" to any "11" and from there to any "10" and we will always find tile "1" - our target that was clicked.

 

Since I was not aware of any native loop functions in Powerapps I basically created a for each loop that loops 45 times and in every cycle it maps a one number higher. So with this limit, my game can calculate paths up to 45 steps which is more than enough. Ideally the path-finding function would exit when the path has been determined but I did not figure out how so everytime path-finding is performed, the loop does all the 45 iterations, even if just 5 were sufficient to count the path. With the small sizes of my maps, this difference is not noticeable. Below is the pathfinding code that is executed every time player click any tile on the map:

 

 

 

 

 

ClearCollect(
        pathfinderqueue;
        {
            x:ThisItem.x; 
            y:ThisItem.y; 
            step: 1 
        }
    );;

    ForAll(
        Sequence(45;1;1) As seq;
        If(
            IsBlank(
                LookUp(
                    pathfinderqueue;
                    x=Player.x And y=Player.y
                )
            );
            ForAll(
                Filter(
                    pathfinderqueue;
                    step = seq.Value
                ) As queue_record;
                If(
                    And(
                        queue_record.x > 1;
                        Index(
                            PathFinder_map;
                            ((queue_record.y - 1) * 15) +
                            queue_record.x - 1
                        ).blocked = false;
                        IsBlank(
                            LookUp(
                                pathfinderqueue;
                                And(
                                    x=queue_record.x-1;
                                    y=queue_record.y
                                )
                            )
                        )
                    );
                    Collect(
                        pathfinderqueue;
                        {
                            x:queue_record.x - 1;
                            y:queue_record.y;
                            step: seq.Value + 1
                        }
                    )
                );;
                If(
                    And(
                        queue_record.x < 15;
                        Index(
                            PathFinder_map;
                            ((queue_record.y - 1) * 15) +
                            queue_record.x + 1
                        ).blocked = false;
                        IsBlank(
                            LookUp(
                                pathfinderqueue;
                                And(
                                    x=queue_record.x+1;
                                    y=queue_record.y
                                )
                            )
                        )
                    );
                    Collect(
                        pathfinderqueue;
                        {
                            x:queue_record.x + 1;
                            y:queue_record.y;
                            step: seq.Value + 1
                        }
                    )
                );;
                If(
                    And(
                        queue_record.y > 1;
                        Index(
                            PathFinder_map;
                            ((queue_record.y - 1 - 1) * 15) +
                            queue_record.x 
                        ).blocked = false;
                        IsBlank(
                            LookUp(
                                pathfinderqueue;
                                And(
                                    x=queue_record.x;
                                    y=queue_record.y-1
                                )
                            )
                        )
                    );
                    Collect(
                        pathfinderqueue;
                        {
                            x:queue_record.x;
                            y:queue_record.y - 1;
                            step: seq.Value + 1
                        }
                    )
                );;
                If(
                    And(
                        queue_record.y < 10;
                        Index(
                            PathFinder_map;
                            ((queue_record.y - 1 + 1) * 15) +
                            queue_record.x
                        ).blocked = false;
                        IsBlank(
                            LookUp(
                                pathfinderqueue;
                                And(
                                    x=queue_record.x;
                                    y=queue_record.y+1
                                )
                            )
                        )
                    );
                    Collect(
                        pathfinderqueue;
                        {
                            x:queue_record.x;
                            y:queue_record.y + 1;
                            step: seq.Value + 1
                        }
                    )
                )
            ) //ForAll end
        )//If IsBlank(pathfinderqueue(player.x,y)
    );; //ForAll end

    Set(pathfinder;
        Patch(pathfinder;
            {working: false}));;  

 

 

 

 

 

 

 

 

THE FOG OF WAR


fogofwar.gif

There are situations where limiting the visibility of the player creates anxiety and better storytelling. The way I implemented this effect was so that each tile (again, a gallery item), contains a black rectangle that is layered on top of all the other elements in that gallery item. Every single time the screen is updated (= all the tiles are drawn), that rectangle checks if fog of war is active and if it is, how close to the tile's X and Y are the character's X and Y. If the character is too far away, the alpha of that black rectangle is "1". If the character is near'ish, the alpha is ".5" and if the character is closer than three tiles, the alpha for the shadow rectangle is "0". That way the fog of war "follows" the character.

.

.

.

.

"TALK TO ME - WERE IN THIS TOGETHER"


handy.gifI loved the way in old school Lucasarts games where they placed corresponding dialogue above each characters in the game. It's wonderfully nostalgic way to give the player feedback on what they just did or how their interaction with whichever item succeeded or not. I created a text object that float just above the player's location on the screen. When the character has something to say like the player performed some action, a dialogue timer is set which controls how long the dialogue is shown on the screen. 

 

Additionally, the text label follows the movement of the character during it's brief lifetime. There are some minor tweaks so that the subtitle never exceeds the edges of the screen so that's a fun effect.

.

.

.

.

WALK HERE, TAKE THAT: ACTIONS


The action menu on the bottom of the screen. Nothing special, just buttons that set a global variable so that when a tile is clicked, the game knows what the user is trying to do. If the player chooses "TAKE" and then clicks a tile, the game checks if there is anything to be taken. If the player chooses "WALK", then the game does a path-finding operation on the target tile.

 

If the player chooses "LOOK" and clicks a cell, each object in the room / on the map has a description property, which is then displayed as a dialogue. For example, if the player selects "LOOK" and clicks at a tile where there happens to be a painting, the game displays the painting's description, like "That seems to be a painting of the late prince Joakim."

 

 

SUMMARY AND ISSUES


I was suprised how many game mechanics that I like I was able to implement into a canvas app. And more so, none of this are "cheating", like importing libraries, of sneaking code into the app via some tricks. It's all Powerapps scripts or out-of-the-box elements. And by tweaking and investigating reasons why it was slow, I was able to make it faster and faster, especially the path-finding algorithm. When I first implemented it, it took 5 seconds (!) to count the path. Now, it does it in fraction of a second.

 

I did run into at least one potential roadblock: the number of different objects in my app was large enough the the powerapps editor gave me warning. I'm not sure if those are just informative or later on restrictive, hopefully the former.

 

Finally, I haven't finished the game. It only has 5 rooms. I don't have the rights to release or publish the tile set I'm using. I would have to switch the tile set to something else from opengameart.com etc.  The player can't interact with other characters yet. I'm not sure if I'll program a discussion function to actually have a simple dialogue with other characters. Might be easier to make a sneak and steal game where you only interact with others to stab them with the pointy end of your chosen steel. In my mind I have already achieved my goal. I wanted to see if it could be done. To continue from here, to make it a finished version, to publish something, takes grit and time. I'm short on both at the moment. But to get this far and see it run in my environment, that alone makes my nostalgy bone tingle.

 

Cheers,

Mikael

 

 

Comments