Elm 3D Pool Game CollaborationOctober 24th, 2022
Elm 3D Pool Game Collaboration
October 24th, 2022
In November 2020, shortly after the announcement of the Elm 3D Game Jam, I asked Andrey Kuzmin (
@unsoundscapes
) to collaborate on building a 3D pool game.
In this post, I share my Elm 3D Game Jam (#5) experience, including the advantages of how we split the work and how we benefited from using phantom types and the
phantom builder
pattern Jeroen Engels has popularized. I include an additional way to use phantom types.
Separation of work
I thought it would be nice to divide the game rule logic from the physics/rendering. We could then work somewhat independently.
It could also appeal to our strengths. Andrey could utilize his expertise in
elm-physics
,
elm-geometry
, and
elm-3d-scene
to simulate physics and render the world. I could use my experience in pocket billiards (pool) to implement the game rules.
After a Slack conversation and a couple of Zoom sessions, we had a rough outline of the game rules API, what states the physical simulation would need to send to the game rules to determine the next state, and an idea of the user interaction.
Separating the game rules from the physics/rendering was incredibly helpful for testing. It required no need for physical world details. I started working right away on my part.
Initial Game Rules API
My first thought on the game rules API included this:
type Event
= BallToBall Ball Ball
| BallToWall Ball Wall
| CueToBall Ball
| BallToPocket Ball Pocket
Then we could pass in a set of events to determine what happens next.
When I started to implement the game rules, I created an
EightBall
module. I quickly realized I had to think about what must happen before a shot, like racking and placing the ball behind the head string.
Here's a diagram of what I mean by "behind the head string":
type Action player
= Rack (List Ball)
| PlaceBallInKitchen player
update : List ( Time.Posix, Action player ) -> Pool -> Pool
The
Pool
type is
opaque
, so we can prevent the game rules internals from leaking into other modules.
I knew I wanted to constrain the API to allow only valid actions at any point. For example, racking after taking a shot makes no sense, nor does taking a shot after a scratch if the next player does not place the ball back on the table.
I had a
vague notion
that we could use phantom types to ensure valid states, but I had no experience using it myself. So I had another Zoom meeting with Andrey, expressing my intuition. He quickly recognized the relevance and helped to design the API.
Valid API with phantom types
Phantom types are an advanced Elm concept. I recommend the following resources (some linked elsewhere in this post) if they are unfamiliar to you:
We scratched the
update
function for a set of functions constrained to each of the actions. These have a phantom type in their signatures (
Pool phantomTypeHere
). Let's see how it works.
The interaction starts with a
start
function to initialize the game rules state:
start : Pool AwaitingRack
The
start
function might not seem helpful, but it allows us to set an initial state to be used on app
init
.
Then we must rack the balls next:
rack :
Time.Posix
-> Pool AwaitingRack
-> Pool AwaitingPlaceBallBehindHeadstring
It takes a moment in time and a
Pool AwaitingRack
...the same one returned from
start
.
AwaitingRack
is the phantom type. It's used in the type signature to prevent any state other than the initial
start
state from calling it!
Now the
only action possible
is to place the ball behind the head string:
placeBallBehindHeadstring :
Time.Posix
-> Pool AwaitingPlaceBallBehindHeadstring
-> Pool AwaitingPlayerShot
We could call these in a pipeline:
Pool.start
|> Pool.rack (Time.millisToPosix 0)
|> Pool.placeBallBehindHeadstring (Time.millisToPosix 1)
If we tried to switch the order, we would see a compiler error:
Pool.start
|> Pool.placeBallBehindHeadstring (Time.millisToPosix 1)
|> Pool.rack (Time.millisToPosix 0)
-- TYPE MISMATCH ------ elm-pool/tests/EightBallTests.elm
This function cannot handle the argument sent through the (|>)
pipe:
891| EightBall.start
892| |> EightBall.placeBallBehindHeadstring
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The argument is:
EightBall.Pool EightBall.AwaitingRack
But (|>) is piping it to a function that expects:
EightBall.Pool EightBall.AwaitingPlaceBallBehindHeadstring
There are a bunch of things that could happen during a shot needed to determine what happens next:
type ShotEvent
= BallToPocket Ball
| BallToWall Ball
| CueHitBall Ball
| CueHitWall
| Scratch
These events are sent in the player's shot:
playerShot :
List ( Time.Posix, ShotEvent )
-> Pool AwaitingPlayerShot
-> WhatHappened
This is where things get interesting...
Handling WhatHappened
WhatHappened
Here is where the potential actions can diverge. We might think about reaching for extensible records or simply allowing
Pool whatever
, but we decided to use a custom type for this to constrain each of the possible outcomes:
type WhatHappened
= NextShot (Pool AwaitingPlayerShot)
| PlayersFault (Pool AwaitingPlaceBallInHand)
| GameOver (Pool AwaitingStart) { winner : Int }
Handling NextShot
NextShot
NextShot
is the case when everything is going as planned. Players keep shooting back and forth.
Handling PlayersFault
PlayersFault
If a player scratches or hits the wrong balls (example: stripes instead of solids), the
PlayersFault
case returns a
Pool AwaitingPlaceBallInHand
that can only be passed into the function
placeBallInHand
:
placeBallInHand :
Time.Posix
-> Pool AwaitingPlaceBallInHand
-> Pool AwaitingPlayerShot
Handling GameOver
GameOver
Lastly, the
GameOver
variant includes the winner. It requires
start
again because there are no functions that accept
Pool AwaitingStart
as an argument.
All together now
Here's an example of how this API might be used in a test:
let
whatHappened =
EightBall.start
|> EightBall.rack (Time.millisToPosix 0)
|> EightBall.placeBallBehindHeadstring
(Time.millisToPosix 1)
|> EightBall.playerShot
[ EightBall.cueHitBall (Time.millisToPosix 1)
EightBall.fifteenBall
, EightBall.ballFellInPocket (Time.millisToPosix 1)
EightBall.tenBall
, EightBall.ballFellInPocket (Time.millisToPosix 1)
EightBall.fifteenBall
]
in
case whatHappened of
EightBall.NextShot pool ->
-- Players keep shooting
-- Expect player is shooting stripes.
EightBall.PlayersFault _ ->
-- Player places ball in hand
-- Expect fail.
EightBall.GameOver _ _ ->
-- Announce winner, option to start a new game
-- Expect fail.
The
WhatHappened
custom type allows only one path but requires all variants to be handled. This is great for this use case but not so nice for a builder pattern because it breaks the pipeline.
Hopefully, this adds another useful way to constrain APIs to your toolbox.
Summary
I learned a lot by participating in the game jam.
The separation of work and API design were closely related. It reinforced the importance of thinking of both together. By separating the game rules from rendering and physics, it simplified collaboration.
Building around a type,
Pool
, ensured high cohesion within the module. By utilizing an opaque type for the game rules module, we reduced potential coupling between the modules. Using phantom types helped to guide the use of the API in only valid ways.
Thanks to Andrey Kuzmin for collaborating with me. Thanks to all the folks who contributed to the many great libraries and tools in the Elm ecosystem. ❤️