Driftpin devlog: a new spin on bowling

This is Driftpin, a cute little bowling game with a new spin. I've been cutting my teeth on the Lua SDK with this project, and I think it's far enough along to share what I'm up to.

Driftpin Demo

The premise should be clear from the video: it's bowling, but you use the crank to control ball spin in order to navigate curvy lanes and various obstacles. It scores like bowling, but it plays a bit like a top-down fixed-screen racer. Each frame will play on its own unique lane, in the spirit of a mini golf course. I've got plans for up to 10 courses, each with their own distinct theme and obstacle types.

I've got the basics in place now — curvy gutters, ball control, boost functionality, and simple pin collision physics., as on this single lane. As you can see, right now the pins are just circles. This works convincingly while standing, less so once struck. I'm not sure what to do about that yet. I could swap the sprite graphic to look like fallen pins, but there's no way I can do proper collision physics for them and it might be more frustrating to see pins clipping through each other without colliding. :thinking:

Next I plan to build score tracking and then set up some form of scene management so I can start building and sequencing the lanes/levels. (I might try out Noble Engine, so any insights on doing that are welcome!)

18 Likes

Great concept! What does the inner part of the big circle mean?

Thanks! That's a first attempt at a "boost" meter. It charges as you corner at speed, and decays a little bit while not actively charging. Once full you can tap A/up for a speed boost that also impacts handling a bit, making it feel more "drifty."

That meter is my first attempt at conveying the spin direction/speed and boost. I'm not sure if the spin direction is critical, but since steering is relative to direction of movement and the crank axis is orthogonal to the screen I thought it might be helpful to visualize. I think a linear meter might be clearer for boost, but I wanted to see if I could keep the footprint small and utilize the otherwise wasted space within the spin meter. It also needs a lot of UX enhancements, including better indication of a fully charged boost, and possibly some indication while boosting that shows it depleting.

3 Likes

This is a really cool concept and can't wait to see how it's expanded on. I think the drift concept can be played with more within these style of games and the crank, it lends itself so nicely.

I was wondering what your thoughts are to level progression. How do you see the game developing for the player?

Thanks! I’m excited to explore the possibilities myself. I plan to create themed levels, each constructed around a particular set of obstacles and ideas. The levels will borrow from a variety of other inspirations, including mini golf, pinball, labyrinth, racing, driftkhana, skating, billiards, and so on. I’ll post updates here to showcase some of the level concepts as I build new obstacles.

In addition to the levels themselves, which I hope will offer a distinct feel and challenge, I plan to explore ball handling possibilities. Unlockable balls with unique stats — weight, charge, boost, drift, etc. — will hopefully mix things up, offer strategic advantages on certain lanes, and suit a variety of play styles. A timed challenge mode on each lane should provide some incentive to experiment with the available options.

1 Like

Keeping Score

Keeping score in bowling is tricky! Which is to say — I know how to keep score, but codifying it in an algorithm was more of a challenge than I expected going in. Getting the total right was one thing; making sure to call spares and strikes correctly even in the anomalous 10th frame took some doing. I'll share a bit about how I approached that below, but first: I put together a nice little scorecard to visualize the score, complete with proper notation, penciled in values for not-yet-final frame scores, and a circled total at game end. This GIF cycles through some randomly generated scores to showcase it.

Scorecard-5

Scoring Basics

In bowling, you score based on the number of pins knocked down in the fame — up to 10 — in (up to) two rolls. If you score all 10 pins in the first roll, it's a "strike" and the score in that frame is 10 plus the sum of the next two rolls (either in the next frame, or the next two frames, depending on whether another strike is rolled). If you score all 10 pins after the second roll, it's a "spare", and the value of the next roll only is added to the score in that frame.

The 10th frame adds complication since you can still attain those extra 2 rolls for a strike on the first throw, or an extra single roll for a spare on the second, for a total of 3 rolls (and up to 3 strikes) in a single frame.

Score Representation

There are a number of data representations that one might use to track the score. One literal option would be to keep track of the first and second roll in each frame (and a possible third in the 10th). Taking this approach requires iterating through the frames to compute the score for any given frame which represents a spare or a strike, looking one or two frames ahead respectively.

I took a different approach, opting to do the iteration backwards when recording a roll, rather than forwards when computing a score. I record each frame is as a list of (up to) 3 values in the range 0..10. I use a FrameScore object to hold this data:

function FrameScore:init(firstRoll, secondRoll, thirdRoll)
    FrameScore.super.init(self)

    -- nil values reflect not-yet-bowled rolls
    self.rolls = {firstRoll, secondRoll, thirdRoll}
end

In practice, values are only passed to init when loading saved game state. In a new game, they are all left nil to start, and they are populated in succession with calls to FrameScore:recordRoll(pins, forRoll). forRoll is actually optional as well (it allows for overriding score values) as by default it will record the first, then the second, and optionally a third roll. It also does some integrity checking to make sure you don't do anything illegal, such as record a third roll for a frame without a strike or spare, or record more than 10 pins total in a given frame. Apart from the various checks on the integrity of the data, the function basically amounts to:

function FrameScore:recordRoll(pins, forRoll)
    -- record the next roll unless otherwise specified
    local n = forRoll and forRoll or #self.rolls + 1

    -- integrity checks omitted…

    self.rolls[n] = pins
end

Here n is the roll number in the frame, which is determined automatically if omitted in the call to recordRoll. In addition to recordRoll(…), I have functions to check if the frame isStarted, isFinal (that is, all rolls which impact the score in the frame have occurred), isOpen (no strike/spare in the frame), isStrike, isSpare, and one to get the total scoredPins for the frame (which can be computed as the sum of the the fields within a single FrameScore object).

Then, I have a GameScore object which holds a list of 10 FrameScores, as well as some info about the current frame and current roll number.

function GameScore:init(data)
    GameScore.super.init(self)

    if data then
        self:deserialize(data)
    else
        self.currentFrame = 1
        self.currentRoll = 1
        self.strikeStreak = 0

        -- create our empty frame scores
        self.frameScores = {}
        for i = 1, NUM_FRAMES do
            self.frameScores[i] = FrameScore()
        end
    end
end

It has its own recordRoll(…) function which proxies to the current frame's recordRoll by default. Whenever this function is called, it will also iterate backwards from the current frame to any frames for which isFinal() returns false, recording rolls there as well to account for past strikes and spares.

function GameScore:recordRoll(pins)

    -- integrity checks omitted…

    -- iteratively update current and previous frames to account for strikes/spares
    local n = self.currentFrame
    while n > 0 and not self.frameScores[n]:isFinal() do
        self.frameScores[n]:recordRoll(pins)
        n -= 1
    end

    -- logic to update current roll and frame number omitted…
end

Both FrameScore and GameScore have serialize() and deserialize(data) functions which enable me to save the current state of the game, including the scores, the current frame, and the current roll. Separately, I have this same logic built for the PinRack in order to save the state of the pins themselves.

The 10th Frame

The approach I chose makes the 10th frame behave nearly identically to the other frames with regard to score representation, which is nice. The representation also makes it easy to implement the scorecard, since I can iterate without needing to "look ahead" as I render data for each frame. The score is just the sum of the recorded rolls in that frame, and the helper functions on the FrameScore object make it easy to check for strikes/spares and use the right scoring notation.

Determining when to show "strike", "spare", and "split" cards on screen in response to hitting the pins on any of the possible three rolls in the 10th frame was an extra challenge. Calling a strike on the first is easy. After that, things get messier. Knocking down all pins on the second roll could be a spare or a strike depending on the outcome of the first roll. I’d consider it a strike to knock down all 10 pins at once on the third roll; but is it a spare if you roll a strike on the first roll, fewer than 10 on the second, and the remainder in the third? Determining when to rerack in the 10th frame has similar complexity.

The key insight here was to check when scoredPinsThisFrame() % 10 == 0, which indicates the need for a rerack regardless of which roll it occurs on. A variation of this combined with checking the roll number is in place to determine when to announce strikes and spares. It feels a bit uncomfortable to have special cases in the code, but then again, the 10th frame is essentially a special case.

Up Next

I'm glad to have this bit of administrative work out of the way so I can soon turn attention back to building new obstacles, lanes, and courses. There's just one major technical hurdle I need to clear before the fun stuff: setting up scene management so I can logically structure sequences of lanes and manage transitions between various game states.

4 Likes

Loving this Dev log. And the game is right up my alley!

4 Likes

Detecting Splits

Here's a little diversion. I thought it would be fun to try out the SDK’s pathfinding APIs to detect splits. Although this doesn't have any direct impact on gameplay, I think that the game's ability to acknowledge celebratory moments (e.g. strikes, doubles, etc.) as well as cringeworthy ones (e.g. gutterballs, splits, etc.) will give it an empathetic voice and create a stronger emotional connection for the player. At the very least it should add to the atmosphere and sense of authenticity.

split

Wikipedia defines a split as:

…a situation in ten pin bowling in which the first ball of a frame knocks down the headpin ("number 1 bowling pin") but leaves standing two or more non-adjacent groups of one or more pins.

The Approach

That sounds like a graph problem! Restated in graph terms, we basically want to determine whether the set of pins that remain standing when the headpin is struck represent a fully connected graph, or contains two or more disconnected subgraphs. Some graph APIs have built-in facilities for determining this, which would make our job easy. Alas, Playdate does not. Its graph API, understandably, is focused on finding paths between nodes as would be useful in tile-based games or games played on a regular board.

No big deal; all the primitives we need are still there. The basic approach is to do a "flood fill" of the graph from an arbitrary standing pin in order to see if we can reach all of the others. If we fail to reach one or more pins, that means there must exist at least one pin which is fully disconnected from the subgraph we started from, and hence, a split!

Constructing the Graph

Before we can traverse the graph, we need to construct it. Bowling pins are arranged in a triangle. If you consider the 5 pin in the middle, it's clear that their layout actually represents a hexagonal grid, as it is adjacent to the 2, 3, 4, 6, 8, and 9 pins — a total of 6. The API provides convenience initializers for regular 2D grids (with or without diagonals — so, with 4 or 8 adjacencies) but none for graphs of degree 6.

hex-grid

Here’s the code used to construct the graph connections. It iterates through the rows and, for each node, creates connections to the node to its right as well as the nodes in the next row just to the left and right. This is sufficient since all connections are made reciprocal. The IDs for the nodes match the numbers of the pins. (Note that these directional references pertain to a rack oriented with the head pin facing downward, so you'll have to imagine the above image rotated 90º CCW. Note also that the graph has already been initialized and populated with nodes for each of the 10 pins before this step, which only happens once.)

local n = 1 -- the current pin number
for i = 1, NUM_ROWS do
    for j = 1, i do
        if j < i then
            self.g:addConnectionToNodeWithID(n, n+1, weight, reciprocal) -- right
        end
        if i < NUM_ROWS then
            self.g:addConnectionToNodeWithID(n, n+i,   weight, reciprocal) -- behind left
            self.g:addConnectionToNodeWithID(n, n+i+1, weight, reciprocal) -- behind right
        end
        n += 1
    end
end

The weight is always 1 (the pins are equidistant, although this doesn't matter for our purposes) and all connections are reciprocal. While it would be possible to update the graph in real time by adding and removing connections as pins are struck and reracked, in practice that felt like unnecessary effort with a risk of error. Instead, I reconstruct the graph based on the currently standing pins each time isSplit() is called by removing the connections for all struck pins from the fully connected graph we created above:

-- remove connections for pins which have been struck
local startPinID
for i, pin in ipairs(self.pins) do
    if pin.struck then
        self.g:removeAllConnectionsFromNodeWithID(i, true)
    else
        startPinID = i -- doesn't matter which, just any standing pin
    end
end

In the process, we capture a reference to a pin to start the traversal from. It doesn't matter which pin we start from — any standing pin node will do. After removing the nodes, we'd have a graph that looks something like this example split:

split-grid

Traversing the Graph

A flood fill can be achieved with either a depth-first or breadth-first search of the graph. I chose a depth-first approach, which can be easily implemented as a recursive function.

-- a simple depth first visitation function which returns the visited nodes
local DFS
DFS = function(node, visited)
    -- avoids the need to pass an empty visited list before recursing
    visited = visited or {}

    -- bail out if we've already visited or node is nil
    if not node or visited[node] then return visited end

    -- mark the node visited
    visited[node] = true

    -- visit all directly connected nodes
    local connections = node:connectedNodes()
    for _, c in ipairs(connections) do
        DFS(c, visited)
    end

    return visited
end

Let's break it down.

local DFS

First, we declare the local variable which will hold the reference to the function. This enables us to call the function recursively from within its own body.

DFS = function(node, visited)

The DFS function takes a playdate.pathfinder.node and the list of visited nodes which builds up as we traverse the graph.

visited = visited or {}

This line isn't strictly necessary. It provides an empty list of visited nodes by default, which means that the second argument may be omitted when calling DFS directly.

if not node or visited[node] then return visited end

This is the base case. If we've already visited the node, we bail out, otherwise we'd recurse infinitely. Checking for a nil node also handles the case where, for instance, there are no standing pins so we couldn't find a pin node to start from, avoiding the need to check for this outside the function.

visited[node] = true

We record visited nodes by inserting them into the table as keys with a value of true. This makes the lookup O(1), which doesn't matter much for such a small graph but still feels like the best approach, and also makes it possible to utilize the visited nodes later on.

local connections = node:connectedNodes()
for _, c in ipairs(connections) do
    DFS(c, visited)
end

Next, we fetch the list of all nodes connected to the current node. We iterate over those nodes, recursively calling DFS on each with the current list of visited nodes.

return visited

In the end, our traversal function returns the list of visited nodes. There are lots of other forms of visit functions we could create, including some that apply a function to each visited node, but the list of visited nodes is sufficient for the purposes of split detection. (In fact, we could have just counted them, but this approach seems more generically useful.)

Putting It To Use

Time to call it! We start the traversal from the node with the ID of our arbitrary standing pin. Once we have the list of visited nodes, all that's left is to compare the number of visited nodes to the number of standing pins. One gotcha here is in counting up the visited list. In Lua, the # operation is only well-defined for numerically indexed tables. Our visited list uses nodes as keys, so we have to use pairs (not ipairs, which also works only for numerically indexed tables) to iterate through them and come up with a valid count.

-- do a DFS over the pins starting from our standing pin node
local visited = DFS(self.g:nodeWithID(startPinID))

-- count the visited nodes
numVisited = 0
for node in pairs(visited) do numVisited += 1 end
local numStanding = #self:standingPins()

-- if we didn't visit every standing pin, it's a split!
return numVisited < numStanding

Implementation Notes

This approach works well for my needs. It’s worth noting that using the nodes as keys means that the resulting visited list doesn’t reflect the order in which the nodes were visited. It might also be nicer from an API perspective to return a numerically indexed list of nodes instead of the map, but that requires extra bookkeeping or an extra step with a helper function, and just didn’t seem worth it for my simple use case.

Summing Up

In ~40 lines of Lua code, I was able to implement my PinDeck:isSplit() function, which I call after each roll in order to show feedback to the player in the event of a split. That’s a little more code than I’d expected going in, but it works like a charm.

I could take this a step further and devise a way to detect specific types of splits too, such as the dreaded 7-10 or "goal posts" split (perhaps by defining bitmasks for each to compare with a bitmask representation of the standing pins?), but that can wait. I need to stop finding excuses to avoid the impending scene management work…

6 Likes