Zero to Playable
We are going to build Star Catch: stars fall from the sky, you slide a wooden tray to catch them, you have three lives, the pace ramps up. Keyboard on desktop, drag on phones, one file, about 250 lines. Every line of this chapter compiles as printed; type it in or copy it whole.
The point of the exercise is not the game (though it is weirdly moreish). The point is that by the end you will have touched every part of the machine: the model, the tick, the draw list, input from two kinds of device, seeded randomness, and phase management. The rest of the book is a guided tour of the things you are about to do quickly.
Setup
A Mar game is an ordinary frontend project: a folder with a manifest and a Main.mar.
{
"name": "star-catch"
}
Save that as star-catch/mar.json, put the code from this chapter in star-catch/Main.mar, and run:
mar dev star-catch
The dev server opens the game in your browser and hot-reloads on every save, keeping your Model alive across edits. Tune a constant mid-game and the game continues with the new value, which is a preposterous luxury for game tuning and you will miss it everywhere else.
The world
module Main exposing (main)
import Canvas exposing (canvas, rect, circle, text, rgb, onTap, onDrag, onResize)
import Keyboard
import Time
type Phase
= Title
| Playing
| Over
type alias Star =
{ x : Int, y : Int, spd : Int }
type alias Model =
{ w : Int, h : Int -- canvas size, live (onResize)
, trayX : Int -- tray center
, left : Bool, right : Bool -- held keys
, stars : List Star
, seed : Int -- randomness lives IN the model
, spawnT : Int -- ticks until the next star
, score : Int
, lives : Int
, phase : Phase
}
The whole game is this one record. There is no scene, no sprite objects, no manager classes; when we want to know anything about the game, it is a field, and when anything happens, it is a new record. Three observations before moving on:
- The screen size lives in the Model. A canvas game receives its size from the runtime (via
onResize) and lays itself out fromwandhevery frame. Nothing is hard-coded to a resolution, so the same game fits a phone and a desktop window. This style is called reflow and it is the default posture of every example game. - Held keys are state. Keyboards deliver events (down, up), but movement wants a level (“is left held right now?”). The two booleans convert one into the other.
- The random seed is a field. Chance is data like everything else. Chapter 6 (Deterministic Chance) is entirely about why this is wonderful.
The messages are everything the world can learn:
type Msg
= Resized Int Int
| Tick
| KeyDown Keyboard.Key
| KeyUp Keyboard.Key
| Tapped Int Int
| Dragged Int Int
init : (Model, Cmd Msg)
init =
( { w = 360, h = 640
, trayX = 180
, left = False, right = False
, stars = []
, seed = 20260717
, spawnT = 30
, score = 0
, lives = 3
, phase = Title
}
, Cmd.none
)
The math kit
Mar’s standard library is deliberately small, and games always need the same four tools. Write them once at the top of every game (or in a shared module); we will use these exact five in every later chapter:
imax : Int -> Int -> Int
imax a b =
if a > b then a else b
imin : Int -> Int -> Int
imin a b =
if a < b then a else b
clamp : Int -> Int -> Int -> Int
clamp lo hi n =
imax lo (imin hi n)
-- positive remainder (there is no modBy; // truncates toward zero)
modI : Int -> Int -> Int
modI a m =
let
r = a - (a // m) * m
in
if r < 0 then r + m else r
-- Park-Miller LCG: next seed, plus a value in [0, n)
rnd : Int -> Int -> (Int, Int)
rnd seed n =
let
s2 = modI (seed * 16807) 2147483647
in
( s2, modI (s2 // 64) (imax 1 n) )
Note //: Mar’s integer division. Games use it constantly and it truncates toward zero, so modI patches negatives back into range. The rnd function is the classic Park-Miller generator, the same five lines every example game carries: give it the current seed, get back the next seed and a number. Threading that seed through the model is how the whole game stays replayable.
Update: the rules of the game
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Resized w h ->
( { model | w = w, h = h, trayX = clamp 45 (w - 45) model.trayX }
, Cmd.none
)
Tick ->
( step model, Cmd.none )
KeyDown key ->
case model.phase of
Playing ->
case key of
Keyboard.ArrowLeft -> ( { model | left = True }, Cmd.none )
Keyboard.KeyA -> ( { model | left = True }, Cmd.none )
Keyboard.ArrowRight -> ( { model | right = True }, Cmd.none )
Keyboard.KeyD -> ( { model | right = True }, Cmd.none )
_ -> ( model, Cmd.none )
_ ->
case key of
Keyboard.Space -> ( start model, Cmd.none )
Keyboard.Enter -> ( start model, Cmd.none )
_ -> ( model, Cmd.none )
KeyUp key ->
case key of
Keyboard.ArrowLeft -> ( { model | left = False }, Cmd.none )
Keyboard.KeyA -> ( { model | left = False }, Cmd.none )
Keyboard.ArrowRight -> ( { model | right = False }, Cmd.none )
Keyboard.KeyD -> ( { model | right = False }, Cmd.none )
_ -> ( model, Cmd.none )
Tapped _ _ ->
case model.phase of
Playing -> ( model, Cmd.none )
_ -> ( start model, Cmd.none )
Dragged x _ ->
( { model | trayX = clamp 45 (model.w - 45) x }, Cmd.none )
start : Model -> Model
start model =
{ model
| stars = []
, score = 0
, lives = 3
, spawnT = 30
, trayX = model.w // 2
, left = False
, right = False
, phase = Playing
}
Input is boring on purpose. Key events flip booleans; a drag teleports the tray to the finger (clamped); a tap starts or restarts depending on the phase. All the actual game lives in step, the function the tick calls:
-- one 16ms step of the world: move the tray, move the stars,
-- score the caught ones, charge the missed ones, maybe spawn
step : Model -> Model
step model =
let
dx =
(if model.right then 7 else 0) - (if model.left then 7 else 0)
trayX =
clamp 45 (model.w - 45) (model.trayX + dx)
moved =
List.map (\s -> { s | y = s.y + s.spd }) model.stars
caught =
List.filter (hitsTray model trayX) moved
falling =
List.filter (stillFalling model trayX) moved
missed =
List.length moved - List.length caught - List.length falling
lives =
model.lives - missed
spawned =
spawn { model | trayX = trayX, stars = falling }
scored =
{ spawned
| score = model.score + List.length caught
, lives = lives
}
in
if lives <= 0 then
{ scored | phase = Over }
else
scored
hitsTray : Model -> Int -> Star -> Bool
hitsTray model trayX s =
let
trayY = model.h - 70
in
s.y + 8 >= trayY && s.y <= trayY + 20 && s.x >= trayX - 53 && s.x <= trayX + 53
stillFalling : Model -> Int -> Star -> Bool
stillFalling model trayX s =
if hitsTray model trayX s then
False
else
s.y <= model.h + 12
-- count down, then push a fresh star with a random column and speed
spawn : Model -> Model
spawn model =
if model.spawnT > 1 then
{ model | spawnT = model.spawnT - 1 }
else
let
(s2, rx) = rnd model.seed (imax 40 (model.w - 40))
(s3, rv) = rnd s2 4
star = { x = 20 + rx, y = 0 - 20, spd = 3 + rv }
pace = 34 - imin 18 (model.score // 3)
in
{ model
| stars = List.concat [ model.stars, [ star ] ]
, seed = s3
, spawnT = pace
}
Read step as a pipeline of worlds: the tray moves, every star falls, the fallen are sorted into caught, still falling, and missed, and a fresh world is assembled from the parts. Notice that nothing is ever removed or mutated; “the star was caught” simply means the next world’s list does not contain it.
Two details worth pocketing:
spawnreturns the model with the spawned star and the new seed. Every call torndmust thread its seed forward, and the compiler will not remind you: reusing an old seed produces the same “random” number forever. It is the one discipline this style demands.- Difficulty is a formula, not a system:
pace = 34 - imin 18 (model.score // 3)makes stars spawn faster as you score, capped so it stays humanly possible. Most of game design at this scale is small formulas like this one, and hot reload makes tuning them a joy.
View: the frame is a list
view : Model -> View Msg
view model =
canvas Pixelated [ onResize Resized, onTap Tapped, onDrag Dragged ]
(case model.phase of
Title ->
List.concat [ backdrop model, titleScreen model ]
Playing ->
List.concat [ backdrop model, playfield model, hud model ]
Over ->
List.concat [ backdrop model, playfield model, hud model, overScreen model ]
)
backdrop : Model -> List Shape
backdrop model =
[ rect 0 0 model.w model.h (rgb 11 16 38) ]
playfield : Model -> List Shape
playfield model =
let
trayY = model.h - 70
in
List.concat
[ List.map (\s -> circle s.x s.y 7 (rgb 246 196 83)) model.stars
, [ rect (model.trayX - 45) trayY 90 14 (rgb 232 228 218)
, rect (model.trayX - 45) (trayY + 14) 8 10 (rgb 202 160 106)
, rect (model.trayX + 37) (trayY + 14) 8 10 (rgb 202 160 106)
]
]
hud : Model -> List Shape
hud model =
List.concat
[ [ text 16 34 22 Canvas.Left (rgb 232 228 218) (String.fromInt model.score) ]
, List.map
(\i -> rect (model.w - 26 - i * 22) 20 14 14 (rgb 217 88 59))
(livesRange model.lives)
]
livesRange : Int -> List Int
livesRange n =
if n <= 0 then
[]
else if n == 1 then
[ 0 ]
else if n == 2 then
[ 0, 1 ]
else
[ 0, 1, 2 ]
titleScreen : Model -> List Shape
titleScreen model =
let
cx = model.w // 2
cy = model.h // 2
in
[ text cx (cy - 60) 34 Canvas.Center (rgb 246 196 83) "STAR CATCH"
, text cx cy 15 Canvas.Center (rgb 232 228 218) "catch stars, keep your lives"
, text cx (cy + 44) 14 Canvas.Center (rgb 143 163 216) "tap, or press Space"
]
overScreen : Model -> List Shape
overScreen model =
let
cx = model.w // 2
cy = model.h // 2
in
[ rect 0 (cy - 70) model.w 140 (rgb 8 11 26)
, text cx (cy - 18) 26 Canvas.Center (rgb 246 196 83) "GAME OVER"
, text cx (cy + 22) 16 Canvas.Center (rgb 232 228 218)
("you caught " ++ String.fromInt model.score ++ " stars")
, text cx (cy + 52) 13 Canvas.Center (rgb 143 163 216) "tap or press Space to try again"
]
canvas Pixelated [attrs] shapes is the whole rendering API. The first argument picks the scaling flavor (Pixelated upscales with hard pixels, the retro look; Crisp stays smooth). The attributes subscribe to pointer events and size changes. The body is a List Shape, drawn in order, first is deepest. That is all a frame is: a list, rebuilt from the Model sixty times a second, and the runtime does the blitting.
Because the frame is an ordinary value made by ordinary functions, screens compose with List.concat and nothing else. The game-over overlay is the playfield plus four more shapes on top. There is no z-index, no layers system: later in the list means in front.
Subscriptions: what the world feeds you
subscriptions : Model -> Sub Msg
subscriptions model =
case model.phase of
Playing ->
Sub.batch
[ Time.every (Time.millis 16) (\_ -> Tick)
, Keyboard.onKeyDown KeyDown
, Keyboard.onKeyUp KeyUp
]
_ ->
Keyboard.onKeyDown KeyDown
This function is the pause button, the difficulty switch, and the battery saver, all in one place. It is called after every update, and whatever it returns is what the runtime delivers. While Playing, we ask for a 16 millisecond tick and the key streams. On the title and game-over screens we ask only for key-downs, so the game consumes nothing while idle: no timers to cancel, no listeners to unregister, no cleanup bug in five years. Declaring “what I am listening to” instead of managing “what I subscribed to” is MVU’s quietest superpower, and games exercise it harder than any app.
Wiring
page : Page
page =
Page.create
{ path = "/"
, title = "Star Catch"
, init = init
, update = update
, view = view
, subscriptions = subscriptions
}
main : Cmd ()
main =
App.frontend [ page ]
A game is a page; a page is five values in a record. App.frontend means no backend and no database, just a served, hot-reloading app.
Play it
mar dev star-catch, then:
- Resize the window. The tray, HUD, and spawn columns follow, because everything is computed from
model.wandmodel.h. - Open it on your phone (the dev server prints a LAN URL). Dragging works because we wired
onDrag; nothing else changed. - Lose on purpose. Watch the phase machinery: the overlay appears, the tick stops (check your laptop fan), Space restarts.
- Edit mid-game. Change the tray width to 140, save, and keep playing without losing your score. Then change
paceand feel the difficulty shift live.
Fifty minutes, one file, two platforms. The next part of the book slows down and explains why each piece is shaped the way it is, starting with the loop itself.