The perpetual quest for a perfect cursor


The cursor system in Sparrow Solitaire is by no means perfect. I don’t think it ever will be– but with every new patch, more kinks get worked out. A cursor doesn’t seem like a big deal, but early on in the development of the game I made it one, and I’ve been dealing with that choice ever since. 😅

The Primary Interface

The cursor is the primary interaction with the game, and it needed to feel good. Mahjong solitaire is best known as a game called Shanghai released on Macintosh back in 1986, and was designed around the ability to use a mouse to quickly select matching tiles. So what do you do when your device doesn’t have a mouse? I watched videos of old console versions for reference such as this one for Shanghai on the Gameboy or this one for Shanghai II: Dragon’s Eye on the SNES. It seems the most common approach for a mouse-less input device was to have the D-pad simply move the cursor like a mouse, often in only four directions. Although I’ve never played any of these console versions (Matt did that play-testing, from the time of his prototype and on through development), the cursor felt slow and imprecise. This homebrew version for the Sega Master System was somewhat unique in that it used a grid-based cursor, which is more apt for the control method than simply replicating a mouse but still not as quick.

Unsatisfied with the existing options, I decided to make a cursor system that would be inherently fun to use (or at least more bearable). I figured I could simply compute the nearest-neighbors in the Left/Right/Up/Down directions for each tile and then have the cursor traverse through this 2D doubly-linked list. Easy right?

The Title Snapping System

The first hurdle was that the mahjong solitaire grid isn’t 1:1 between tiles and grid positions. The grid is twice as big as the maximum width and height of the biggest possible tile layout, since tiles can slot into half-positions. This means the closest neighbor in the ‘up’ direction for example, may not be directly above but a half-position to the left or right as well. In addition, how do we update the neighbors when a tile is removed from the grid? Redirecting the pointers for the all the neighbors of the removed tile isn’t so simple, because they can’t simply point to each other to fill in the gap. Removing a tile often causes new tiles to become ‘free’, or exposes another tile underneath on a lower layer. While I could try to check every possible configuration and way a tile can be removed to free new tiles, it would lead to messy and error-prone code. Instead, I chose to recompute all neighbors after every pair of tiles is removed, which has its own set of problems but is much simpler.

Neighbor Computations

Each time the layout changes in some way (the tiles are re-dealt, a matching pair is made, or a move in undone/redone), all the neighbors are recomputed. This happens by first calculating the set of “snappable tiles”. In free tiles mode, this set is only the tiles that are free. In all tiles mode, this set includes any tile that is not buried by another tile (on the surface layer). In fact, we can entirely ignore the z-level (layer) of a tile after this calculation is complete, since neighbors are only computed in the four two-dimensional directions.

Initially, I simply iterated through every snappable tile and computed its distance to every other tile in each of the four directions. It quickly became clear that this naive approach would not be performant on the Playdate’s processor. Perhaps the processing workload required for this approach is the reason it had never before been attempted on systems lacking a mouse? Instead, I pre-sorted the tiles into two separate lists, one sorted by X position, and one sorted by Y position. For any given tile along these lists, I could look for the closest in the X axis and the closest in the Y axis, simply by splitting off from the index of the given tile, and looking at i+1 or i-1 depending on the direction. But before choosing the closest tile as the correct neighbor, a couple of important filters are applied:

  1. It has to be within +/-1 grid spaces on the axis (for example, this allows a left neighboring tile in the negative X direction to be half a row higher or lower than the given tile in the Y direction).
  2. If there are no tiles that meet this criteria in the given direction, we widen the “cone” to +/- 3 rows or columns off axis.
  3. If there are still no tiles that meet this criteria, we pick the farthest in the opposite direction to create a wrap-around effect.

The actual code is considerably more complex than this and has other optimizations (like only computing a wrap-around tile when the cursor moves there, instead of upfront to reduce unnecessary calculations), but that’s the gist and illustrates our tiered-approach to neighbor selection. Based on several increasingly lax parameters, we choose the best neighbor for each of the four directions for every tile.

Processing Speed

After many weeks of optimization, I got this whole process down to about 0.8 seconds on device, when all tiles mode is enabled. The exact time varies by layout and how many exposed snappable tiles there are, which is why choosing free tiles snap mode (where the cursor only moves to free tiles) speeds up performance by about 0.3 seconds. Fortunately, this processing delay is not that noticeable in practice, since the animation for tile removal intentionally takes around 0.7 seconds to complete.

Playtesting with Matt drawing on screenshots to determine where the cursor should jump from certain starting locations.

The Grid Cursor

When we were playtesting the cursor snapping modes, we noticed the cursor wouldn’t always go where we’d expect. This was especially bad when wrapping around to the other side of the layout. Lots of parameter tuning improved this, but I still wanted an alternative cursor setting for those that preferred absolute control. Although snapping only to the tiles in the layout is faster, it is admittedly a little jarring when you press “up” on the D-pad and the cursor moves to some tile that’s a few spaces to the right, but is technically on a higher row than where you started. So that’s where the grid cursor comes in.

Our grid cursor mode is a bit more thoughtful than the classic console examples I linked earlier. Instead of simply moving the cursor like a mouse in four directions, the D-pad moves the cursor along the grid. However, the grid in this game allows half-positions, but the tiles themselves still take up a full space. The problem here is two-fold: if we were to move the grid cursor by half-position increments, half of the movements would land in between two tiles and nothing would be selectable. But if we were to move the cursor in full-position increments, it wouldn’t be able to select tiles that are positioned on the half positions! You can see where this is going…

Yes, the grid cursor also has some intelligence- in short, we move by full-positions, but also check for tiles in the half-positions along the way. If there’s a tile in the half position, the cursor snaps to it instead of completing the full movement. Multiply this check out by all the layers (and ensure the only tiles that count towards the check are those on the top layer), and you have the basic principle of the grid cursor. There’s a little bit of extra work here to dynamically compute the bounding box of the whole layout (so we can wrap to the other side without having the player waste time by traversing through empty space), but that’s it! This cursor mode also comes in handy for our level-editor function which will be in the full release. And, since no neighbor calculations are necessary in this mode, it takes a tiny <0.1 seconds to remove tiles from the layout. Keep that tip handy in case you are ever speed-running the game!

Crank-controlled Cursor?

The Playdate may not have a mouse or a touch-screen, but it does have a crank. Was there a way we could use the crank to assist cursor control? Matt was adamant about finding a use for it, while I was more skeptical. We first tried making the crank cycle through every tile, with the thought that players would scan through the tiles quickly much like using a dial to select a radio station. In practice though, this wasn’t any faster than regular D-pad movement even if we bumped the cursor scanning speed.

Next we had the idea to instead use the crank to move the cursor between “hotspots” like the four corners, four edges, and middle of the screen. At this point we realized that we already had an optional free tiles only cursor mode, so why not just have the crank cycle between the free tiles? This revealed a rather elegant system, where even if you prefer to play on all tiles or grid mode, at any point you could nudge the crank to jump to the nearest selectable tile in the left or right direction. I was quite happy with this approach and now can be considered a crank-controls convert 😊.

Sounds

The final element in having a fun-to-use cursor was the sound design. I had a cute idea from the beginning to play different notes on each directional movement, similar to how the block-placement sound effects in Mario Maker are tuned to the background music. After implementing these chimes, it felt like playing a musical instrument instead of mahjong solitaire. Aside from annoying the player, it had the potential to clash with the different background music tracks instead of complement them. So I worked on a more subdued approach and left this “cursor notes” mode as an option in the music selector.

For my second approach (and what is final in the game), I sampled a scale of mini-marimba notes from Garageband. I wanted a mallet-y sound as it reminded me of the clacking of mahjong tiles. The notes were picked to sound nice with the primary chord progression in the title song “Herbal Remedies”: D3, G3, A3, D4, G4, A4, D5. Instead of playing a different note for each directional input, Matt had the idea to have the cursor play lower notes for lower layers and higher notes for the upper layers, which worked nicely. Finally, I used an upper D5 pitch when the cursor is held down and moving rapidly. This small detail helps convey that speed even further.

Wrapup

Hopefully this post illustrates some of the many hours of thought that went into our cursor modes. As a player, the primary takeaway should be: use one of the snap modes if you prefer minimal D-pad presses to select a tile, or use the grid cursor if you prefer absolute control over the direction the cursor moves. As a developer, maybe this is more a lesson of not over-scoping (if I did this again I probably would have implemented the grid mode only and saved lots of time). If you are trying to implement a similar system for your own game, or have more questions on how we handled the isometric grid, please reach out! I am leaving a lot of details out of this post since it’s already quite long, but am happy to chat further about anything.

—MV

Get Sparrow Solitaire (Playdate)

Buy Now$10.00 USD or more

Comments

Log in with itch.io to leave a comment.

I really love all the thought you’ve put into the cursor movement, it really shows! 

I didn’t know you could move through all free tiles with the crank, that is really great. I’d love to switch to that method of movement, but I use the crank for zooming in and out right now. It’d be amazing to have a different method for zooming so I could switch over to crankin’ through tiles - perhaps using the B button to toggle zoom when a tile isn’t selected? Honestly it would be great to have a different way to zoom anyways, since it is a bit awkward right now with the crank.

Also, I think I’ve run into a rare bug with the current “free tiles” and “all tiles” movement a couple times, by the way - where would be the best place to report that to you all?

Anyways, Sparrow Solitaire is already fantastic, thanks so much for making it! :D

Hey paracosmhq, sorry for the late reply! I’ve added the “B button to toggle zoom” option in the full version which will hopefully be out in April. What was the rare bug you found?