Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 from w and h every 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:

  • spawn returns the model with the spawned star and the new seed. Every call to rnd must 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.w and model.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 pace and 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.