Building a Sliding Picture Puzzle in Microsoft Power Apps

Companion YouTube Videos

This article supports two YouTube videos that walk through the game. If you are new to Power Apps, you may want to watch them first to get a feel for the gameplay and the app's behaviour:

Introduction

This article walks through the design and implementation of a 3x3 sliding picture puzzle game built in Microsoft Power Apps. If you have ever played one of those classic tile-sliding puzzles, where you rearrange scrambled squares to reform a complete image, that is exactly what we are building, but inside a low-code platform.

The game includes:

  • A timer that tracks how long it takes to solve the puzzle.
  • A swap counter that records the number of moves.
  • A background animation that plays beneath the tile area, with an optional More Information link that opens a related web page. It cycles through red, green, and blue every second.
  • A confetti win animation.
  • A small number beneath each tile to assist players who find it difficult to align the images visually. These are called helper tiles. They also serve as an accessibility aid.
  • Gradual colour changes: as time passes; the duration and swap counters slowly shift colour.

Whether you are exploring Power Apps for the first time, or you are a seasoned developer curious about how a low-code environment handles game logic, this walkthrough covers the key concepts: data setup, asset management, shuffle logic, solvability checks, UI layout tricks, and the workarounds needed when familiar programming constructs are not available.

Throughout this article, code is referenced using a naming convention that mirrors the clicks needed to reach it inside Power Apps Studio. The format is:

Object:Grouping:Function

For example, App:OnStart refers to the OnStart property of the App object.

 


What You Need

  • A Microsoft Power Apps environment (a standard licence is sufficient).
  • The downloaded archive, which contains:
    • The .msapp file (the Power App itself).
    • A .zip archive of image assets.
  • A location to host the image files that is accessible via URL.

Installation and Asset Setup

The app fetches tile images at runtime via URL, so the files need to live somewhere web-accessible. Extract the .zip archive into that location.

Import the .msapp file into your environment. You will then need to edit the code so the common base portion of the URL points to where you placed the assets.


Preparing Your Own Images

If you want to create custom puzzles, start with a 600×600 pixel image and slice it into nine 200×200 pixel tiles, each linked to one cell of a 3x3 grid. I use Photoshop's slice function to automate the process. If you know of other (ideally open source) tools that can do this, please share them in the comments.


Give each tile a name that:

  • Clearly identifies it (for your own sanity).
  • Is unique within the directory, so it does not collide with other tile sets stored alongside it.

 

The Data Source

The app uses a data table to manage puzzle selection. Each time the game runs, it computes the current week number, builds a lookup key from it, and reads the matching record. From that record, the app retrieves the filenames it needs for the week's puzzle.

The data source can be anything Power Apps connects to, including SharePoint lists, Dataverse tables, SQL databases, or external services.


The table has the following fields:

Name Type Constraints Comments
Week String Key, unique
Image1 String Required Filename of the image whose final position is r1c1
Image2 String Required Filename of the image whose final position is r1c2
Image3 String Required Filename of the image whose final position is r1c3
Image4 String Required Filename of the image whose final position is r2c1
Image5 String Required Filename of the image whose final position is r2c2
Image6 String Required Filename of the image whose final position is r2c3
Image7 String Required Filename of the image whose final position is r3c1
Image8 String Required Filename of the image whose final position is r3c2
Image9 String Required Filename of the image whose final position is r3c3
Blank String
Filename of the blank tile
Animation String
Filename of the background animation
Link URL
The URL opened when More Information is clicked
Reserved Boolean Default: No Whether the week is reserved

 


All game assets, image tiles, animations, and the default blank tile must live in a location reachable through a URL. The game logic assumes every asset is hosted in the same base location.

Every field except Reserved is referenced in the game. Reserved is a hint, not a hard rule: it flags weeks that should not be overridden, which is useful when several people contribute puzzles to the same database, particularly around holidays and special events. The game logic does not check this field.

Game State: Internal Representation

The game state is stored as a single string of nine digits, for example "324567981". Each digit represents a piece of the picture, with 9 standing in for the blank tile.

The function that generates the random sequence is App:Formulas:nfShuffle():


// Shuffle the blocks
nfShuffle():Text = {
   Concat(Shuffle(Sequence(9)), Value)
};

The string is read left to right and mapped onto the 3x3 grid, filling each row from left to right and then moving down to the next row.

Mapping Rules

String Position Grid Position
1st character r1c1
2nd character r1c2
3rd character r1c3
4th character r2c1
5th character r2c2
6th character r2c3
7th character r3c1
8th character r3c2
9th character r3c3

Example

Given the string "324567981":


Col 1 Col 2 Col 3
Row 1 3 2 4
Row 2 5 6 7
Row 3 blank (9) 8 1

Each digit in the string maps directly to the tile placed at that position on the grid.

Solvability: The Inversion Check

Not every arrangement of tiles in a sliding puzzle is solvable. To determine whether a given starting state can be solved, we use an inversion count.

What is an inversion?

An inversion is a pair of tiles (a, b) where a appears before b in the string but a > b. The blank tile (represented by 9 in this solution) is excluded from the count.

How to count inversions

For each tile in the string (ignoring the blank), count how many tiles after it have a smaller value. Sum these counts across all tiles to get the total inversion count.

Example

Given the string "324567981", with 9 as the blank tile, the tiles in order (excluding 9) are: 3, 2, 4, 5, 6, 7, 8, 1.

Tile Smaller tiles that come after it Inversions
3 2, 1 2
2 1 1
4 1 1
5 1 1
6 1 1
7 1 1
8 1 1
1 (none) 0

Total inversions = 8

The rule (for a 3x3 grid)

For a standard 3×3 sliding puzzle:

  • If the total inversion count is even, the puzzle is solvable.
  • If the total inversion count is odd, the puzzle is not solvable.

Since 8 is even, the state "324567981" is solvable.


nfIsPuzzleSolvable(pTiles : Text) : Boolean = {
    With(
        {
            tmp_tblTiles:
                Filter(
                    ForAll(
                        Sequence(Len(pTiles), 1),
                        {
                            Index: Value,
                            Integer: Value(Index(Split(pTiles, ""), Value).Value)
                        }
                    ),
                    Integer <> 9
                )
        },
        Mod(
            Sum(
                ForAll(
                    tmp_tblTiles As Check,
                    CountRows(Filter(tmp_tblTiles, Index > Check.Index, Integer < Check.Integer))
                ),
                Value
            ),
            2
        ) = 0
    )
};

The function above performs this check. You can read more about its design in this Reddit discussion.

Code Walkthrough

Naming Conventions Used in This App

Before diving into the code, it helps to understand the naming prefixes used throughout. These follow a convention common in the Power Apps community:

Prefix Meaning Example
g Global variable gTiles
c Context (local) variable cDuration
nf Named formula / user-defined function nfShuffle
tm Timer control tmGame
txt Text label control txtR1C1
dbg Debug control dbgTxt1
btn Button control btnLink

Consistent naming makes it possible to identify an element's type and scope at a glance. This matters more than usual in Power Apps because there is no global search-and-replace, and the IDE does not group elements by type.

Global vs Context Variables

If you are coming from procedural development, the distinction between global and context variables is one of the first things that will trip you up.

  • Global variables (set with Set) are visible across every screen in the app. They persist for the lifetime of the session. In this game, most state is global because the app is a single screen: gTiles, gGameOver, gImg1 through gImg9, and so on.
  • Context variables (set with UpdateContext) are scoped to the screen where they are created. In this game, cDuration and cMoves are context variables because they only matter on the game screen and are reset each time the screen loads.

The rule of thumb: if the value is needed on multiple screens, use a global variable. Otherwise, prefer a context variable.

User-Defined Functions

The functions such as nfShuffle, nfIsPuzzleSolvable, nfGameWon, and nfUpdHelpTiles are user-defined functions (UDFs). These are a relatively recent addition to Power Apps (generally available since 2025). They are parameterised, reusable formulas defined in App:Formulas that behave like built-in Power Fx functions.

Before UDFs existed, the only way to reuse logic was to duplicate it wherever it was needed. UDFs let you write a formula once, give it a name and parameters, and call it from anywhere in the app. The { } block syntax allows imperative statements (like Set and Collect) inside the function body.

App:OnStart

App:OnStart initialises the environment. It defines the global variables related to debugging and UI, and it sets up the puzzle for the current week.

The block below is responsible for loading the various components from the table:

  • gBaseURL points to the URL where all the assets live.
  • gWeek holds the lookup key for the data record.

The code first attempts to load the record for the current week. If that record does not exist, it falls back to a catch-all record named W99. The retrieved record is stored in the global variable gRec.

Each tile URL is then built by concatenating:

  • gBaseURL
  • The filename read from the database
  • A cache-busting query string of the form ?v= followed by the current date and time

The cache-busting parameter ensures that browsers and Power Apps never serve a stale, cached image. If a record is updated and a new image uploaded under the same filename, the timestamp will differ on the next run, invalidating any local cache and forcing a fresh download.


// Load the image set for this week
Set(gBaseURL, "https://<url>/");
Set(gWeek, "W" & Text(ISOWeekNum(Today())));

Set(
    gRec,
    Coalesce(
        LookUp(SlidingPImages, Week = gWeek),
        LookUp(SlidingPImages, Week = "W99")
    )
);

Set(gTile1, gBaseURL & gRec.Image1 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gTile2, gBaseURL & gRec.Image2 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gTile3, gBaseURL & gRec.Image3 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gTile4, gBaseURL & gRec.Image4 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gTile5, gBaseURL & gRec.Image5 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gTile6, gBaseURL & gRec.Image6 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gTile7, gBaseURL & gRec.Image7 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gTile8, gBaseURL & gRec.Image8 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gTile9, gBaseURL & gRec.Image9 & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"));
Set(gBlank, gRec.Blank);
Set(gAnimation, gRec.Animation);
Set(gLink, gRec.Link);

If (IsBlank(gBlank),
    Set(gBlank, gBaseURL & "Blank.png"),
    Set(gBlank, gBaseURL & gBlank & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"))
);

If (!IsBlank(gAnimation),
    Set(gAnimation, gBaseURL & gAnimation & "?v=" & Text(Now(), "[$-en-US]yyyymmddhhmmss"))
);

If (IsBlank(gLink),
    Set(gbtnDisplayMode, DisplayMode.Disabled);
    Set(gMIVisible, false);
,
    Set(gbtnDisplayMode, DisplayMode.Edit);
    Set(gMIVisible, true);
);

A few notable behaviours:

  • If gBlank is empty, the default Blank.png tile is loaded.
  • There is no default animation.
  • If gLink is empty, the More Information text is hidden via gMIVisible and the link is disabled via gbtnDisplayMode.



A Note on OnStart vs Named Formulas

Microsoft recommends moving initialisation logic out of App:OnStart and into Named Formulas where possible. Named Formulas are evaluated lazily (only when referenced) and can improve app load time. However, they are declarative: they cannot contain imperative statements like Set or Collect.

In this game, the startup logic is inherently imperative. It fetches a record, builds URLs conditionally, and assigns them to global variables that drive the UI. That makes OnStart the right home for it. If you are building an app where startup values are purely computed (no side effects), Named Formulas are the better choice.

Timer: Conditional Looping

Power Apps does not provide explicit looping constructs such as While. The sliding picture game, however, needs to keep generating shuffles until it produces a layout that is both solvable and not already solved (123456789). The way I worked around this was to use a timer object, tmGetTiles, as a controlled loop.

The pattern is straightforward: each time the timer ends, it generates a new shuffle. If the result is unsolvable or matches the solved state, the timer restarts itself; otherwise it stops, and the game begins.

 


This is not just a puzzle-specific hack. The timer-as-loop pattern is a general technique in Power Apps. Any time you need retry logic, polling, or repeated attempts until a condition is met, a timer with a conditional restart is the standard workaround. You will encounter this pattern in scenarios like polling an API for a result, retrying a failed connection, or processing items in a batch.

UI: Object Properties

Each cell in the 3x3 grid shares identical or related properties. Rather than repeat the same property in each object making up the grid, the properties are linked to a single object, making it easier to alter them quickly by changing a single place rather than having to make the change 9 times.

Setting the value of a property from a variable

Taking r1c1 .. r3c3 button objects as an example, one notices that certain properties such as Width and Height have their value associated with a variable rather than being hard coded. This approach makes it easier to bulk adjust these properties by simply setting a single variable rather than changing each individual object. The fact that global search and replace is not possible in Power Apps further encourages this methodology.

The linking of an object's property is not only necessary for ease of change. In Power Apps one cannot directly alter a property's value when the code is executing but must do so through a variable. For example, the swap of two blocks during the game is achieved by first associating the Image property of each object with a variable and subsequently modifying the variable.


If(
        Mid(gTiles, 1, 1)="9",
            Set(gImg1, gImg2);
            Set(gImg2, gBlank);
			...

The blocks making up the grid are related to one another. r1c1:X is associated with gTitleLPad (defined in App:OnStart). r1c2:X is computed as r1c1.X+r1c1.Width+1. Take the previous cell's X position, add the cell's width and add 1. If you alter r1c1:X you will notice that all the other linked cells adjust automatically.

Not all properties are copyable

Some properties can be copied while others cannot. txtR1C1:Size is set to txtR1C1.Size while trying to set txtR1C1:Font to txtR1C1.Font will throw an error. A workaround is to associate the property with a variable, gtxtRCFont in this code.

Moving the Tiles

Every tile has code in its OnSelect event that moves the tile and then checks whether the puzzle has been solved. The code is similar across all nine tiles, with adjustments for the tile's position. The example below is from r1c1:


If (!gGameOver,
    If(
        Mid(gTiles, 2, 1) = "9",
            Set(gImg2, gImg1);
            Set(gImg1, gBlank);
            Set(gTiles, Mid(gTiles, 2, 1) & Mid(gTiles, 1, 1) & Mid(gTiles, 3));
            UpdateContext({ cMoves: cMoves + 1 });
        ,
        Mid(gTiles, 4, 1) = "9",
            Set(gImg4, gImg1);
            Set(gImg1, gBlank);
            Set(gTiles, Mid(gTiles, 4, 1) & Mid(gTiles, 2, 2) & Left(gTiles, 1) & Mid(gTiles, 5));
            UpdateContext({ cMoves: cMoves + 1 });
    );

    nfUpdHelpTiles(gTiles);

    If (gDEBUG,
        If (!IsBlank(gDbgTxt4),
            Set(gDbgTxt1, gDbgTxt4);
        );
        Set(gDbgTxt3, Text(nfGameWon(gTiles)));
        Set(gDbgTxt4, gTiles);
    );

    If (nfGameWon(gTiles),
        If (gDEBUG,
            Set(gDbgTxt2, "Won!");
        );
        Set(gGameOver, true);
    );
);

The handler only acts if the game is still in progress, which is controlled by gGameOver. Once the puzzle is solved, gGameOver is set to true, effectively disabling further tile movement.

A tile can only move if the blank space is horizontally or vertically adjacent. If the adjacent cell is not blank, nothing happens. Otherwise, the swap is performed:

  • The displayed images for the two cells are exchanged.
  • The internal gTiles string is updated to reflect the swap.
  • The move counter (cMoves) is incremented.
  • The helper number tiles are refreshed.


 

The function nfGameWon does the following:

  • If gTiles is not "123456789", the puzzle is not solved and the function returns false.
  • Otherwise, it triggers the confetti animation, hides the helper number tiles, replaces the blank with the missing ninth tile to display the complete picture, and returns true.
  • When nfGameWon returns true, the calling code sets gGameOver to true.

// Check whether the game has been won
nfGameWon(pTiles : Text):Boolean = {
    If(pTiles = "123456789",
        Set(gvarShowConfetti, true);
        Set(gtxtRCVisible, false);
        nfFullPic();
        true;
    ,
        false
    );
};

Timer: Updates During the Game

A second timer, tmGame, drives in-game updates. It fires once per second.


The code in tmGame:OnTimerEnd is:


UpdateContext(
    { cDuration : cDuration + 1 }
);

If(cDuration < 200,
    Set(gTxtColR, gTxtColR + 1);
    If(gTxtColG >= 2, Set(gTxtColG, gTxtColG - 2));
    If(gTxtColB >= 2, Set(gTxtColB, gTxtColB - 2));
);

If (gMIVisible,
    Switch(
        Mod(cDuration, 3),
        0, Set(gTxtMoreInfoColor, RGBA(255, 0, 0, 1)),
        1, Set(gTxtMoreInfoColor, RGBA(0, 255, 0, 1)),
        2, Set(gTxtMoreInfoColor, RGBA(0, 0, 255, 1)),
        Set(gTxtMoreInfoColor, RGBA(0, 0, 0, 1))     // default (will not trigger with divisor 3, but kept for safety)
    );
);

The timer increments the context variable cDuration, which tracks how long the game has been running. As time passes, it gradually shifts the colour of the duration and click counters from white toward red. It also cycles the More Information text colour through red, green, and blue.

Debugging in Power Apps

Power Apps is limited when it comes to debugging. While code is running, you cannot inspect the value of a variable directly. The workaround I find most useful is to embed lightweight debug output in the app itself.

I added a set of text boxes to the screen named dbgTxt1, dbgTxt2, and so on. Each is bound to a corresponding global variable: gDbgTxt1, gDbgTxt2, and so on. Whatever is written to a global variable is mirrored by the matching text box.


Because Power Apps does not provide a clean way to enable or disable that debug instrumentation, I left the debug code in permanently and gated it behind a global boolean named gDEBUG:

  • When gDEBUG is true, debug information is shown.
  • When gDEBUG is false, the debug controls and any associated logic are silent.

Below are a few examples showing how gDEBUG is used throughout the app.

Source Code
App:OnStart // Debug Code – change gDEBUG to show / hide debug controls
Set(gDEBUG, true);
If (gDEBUG,
  Set(gDbgTxt1, "");
  Set(gDbgTxt2, "");
  Set(gDbgTxt3, "");
  Set(gDbgTxt4, "");
);
App:Formulas If (gDEBUG,
  Set(gDbgTxt3, gWeek);
  Set(gDbgTxt4, gTile1);
);
tmGetTiles:OnTimerEnd If(gDEBUG,
  Set(gDbgTxt1, gTiles);
  Set(gDbgTxt2, Text(nfIsPuzzleSolvable(gTiles)));
);
r1c3:OnSelect If (gDEBUG,
  If (!IsBlank(gDbgTxt4),
    Set(gDbgTxt1, gDbgTxt4);
  );
  Set(gDbgTxt3, Text(nfGameWon(gTiles)));
  Set(gDbgTxt4, gTiles);
);

Enhancements

Below are some ideas for anyone who wants to take this game further. Beyond making the game more interesting, each one is a useful exercise for sharpening your Power Apps skills. If you build any of these, please share your work.

Default Animation

The game already includes a default blank tile, but it does not have a default animation. Adding one would make the fallback experience feel complete.

Adjustable Refresh Frequency

The game logic switches images weekly. You may want to change this to daily, monthly, or some other interval to suit your audience.

Toggle for the Helper Numbers

A switch that shows or hides the small helper numbers of each tile would let players choose between an easier and a more challenging mode.

Timed Game

A timed mode would add real pressure to the gameplay.

A nice variation, reminiscent of arcade-era high-score tables, would be to record the best time for each starting layout, along with the player's name and the date.

Least Moves to Solve

A close cousin to the timed game, but with the score being the smallest number of moves needed to solve a given starting layout.

Joker Mode

Not all puzzles are solvable. In the spirit of Android's developer mode, tapping the blank tile seven times could activate Joker mode, where unsolvable layouts are selected. A subtle indicator somewhere on the screen could signal that Joker mode is active.

Diagonal Swaps

I am not a sliding-puzzle die-hard, but every version I have played has used only horizontal and vertical swaps. Allowing diagonal swaps would be an interesting twist on the classic mechanic.

 


 

Follow This, That and (Maybe), the Other:




Comments

Popular posts from this blog

How to clone and synchronise a GitHub repository on Android

The complete guide to installing, configuring, and managing Plex Media Server on an Ubuntu Server