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
.msappfile (the Power App itself). - A
.ziparchive of image assets.
- The
- 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,gImg1throughgImg9, and so on. - Context variables (set with
UpdateContext) are scoped to the screen where they are created. In this game,cDurationandcMovesare 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:
gBaseURLpoints to the URL where all the assets live.gWeekholds 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
gBlankis empty, the defaultBlank.pngtile is loaded. - There is no default animation.
- If
gLinkis empty, the More Information text is hidden viagMIVisibleand the link is disabled viagbtnDisplayMode.
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
gTilesstring 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
gTilesis not"123456789", the puzzle is not solved and the function returnsfalse. - 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
nfGameWonreturnstrue, the calling code setsgGameOvertotrue.
// 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
gDEBUGistrue, debug information is shown. - When
gDEBUGisfalse, 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.














Comments
Post a Comment