Worlds and Collision
Collision is where “the world is a value” earns its keep or loses it. This chapter covers the three geometries that power every example game (boxes, circles, tiles), and then the one structural pattern that separates correct battle code from a subtle class of bug that shipped in a real RTS before it was caught.
The three geometries
Boxes (AABB). Axis-aligned rectangles overlap when they overlap on both axes. Store half-widths or width/height, whichever your game finds natural:
overlaps : Box -> Box -> Bool
overlaps a b =
a.x < b.x + b.w && b.x < a.x + a.w
&& a.y < b.y + b.h && b.y < a.y + a.h
This is the workhorse: players, enemies, pickups, bullets, zones. At 2D-game speeds, generous boxes feel better than precise ones; a pickup box 20% larger than its sprite reads as “smooth”, and a player hurtbox 20% smaller reads as “fair”. Tune hitboxes as game design, not geometry.
Circles. Two circles touch when the squared distance between centers is within the squared sum of radii, no square root required (chapter 5):
touches : Int -> Int -> Int -> Int -> Int -> Bool
touches x1 y1 x2 y2 rr =
let
dx = x2 - x1
dy = y2 - y1
in
dx * dx + dy * dy <= rr * rr
Circles suit anything round or radial: explosions, auras, “close enough to interact” checks, the soccer ball.
Points. A tap is a point; point-in-rect is two comparisons. All canvas UI (virtual buttons, card hands, map selection) is this.
Tile worlds
For RPGs, dungeons, and platformers, the level is the collision data. The examples store maps as lists of strings, one character per tile, which keeps levels diffable, editable in any editor, and visible in code review:
level : List String
level =
[ "################"
, "#..............#"
, "#..##......~~..#"
, "#..............#"
, "################"
]
tileAt : Int -> Int -> Char
tileAt tx ty =
case List.head (List.drop ty level) of
Nothing ->
'#'
Just row ->
case List.head (List.drop tx (String.toList row)) of
Nothing -> '#'
Just t -> t
solid : Char -> Bool
solid t =
t == '#' || t == '~'
World position to tile position is integer division by the tile size: tx = x // 16. Out-of-bounds returns '#' at both lookups, so the world is safely walled by its own default, and tiles are Char values you can compare and case on directly.
Movement against tiles uses the oldest trick in the book, one axis at a time:
walk : Int -> Int -> Model -> Model
walk dx dy model =
let
x2 = if blockedAt (model.x + dx) model.y model then model.x else model.x + dx
y2 = if blockedAt x2 (model.y + dy) model then model.y else model.y + dy
in
{ model | x = x2, y = y2 }
Trying x and y separately is what makes characters slide along walls instead of sticking to them, and it is the entire secret of good-feeling grid movement. Check the corners of the moving box (both leading corners in the direction of travel), not its center.
Two speed rules keep tunneling away without real math: keep per-tick movement below the tile size, and keep bullets slower than the thinnest wall, or fatten the bullet’s box by its per-tick travel. Every example game lives happily inside those bounds.
Many against many: build the next world in one pass
Here is the load-bearing pattern. Shots hit foes; foes lose health; shots disappear; scores accrue. The tempting shape is a List.map over foes that computes damage inline, plus a separate filter for shots. The trap: any value you compute inside one branch of that map and do not carry out of it is gone. A real RTS shipped a bug where combat damage was computed inside a unit-update fold and accidentally discarded, so every shot did its work only on the tick it spawned. Nothing crashed; the game was just silently wrong.
The robust shape is a single fold that carries all the outputs in one accumulator record:
type alias Battle =
{ foes : List Foe, shots : List Shot, hits : Int }
resolve : List Foe -> List Shot -> Battle
resolve foes shots =
List.foldl hitFoe { foes = [], shots = shots, hits = 0 } foes
hitFoe : Battle -> Foe -> Battle
hitFoe acc foe =
case List.filter (touching foe) acc.shots of
[] ->
{ acc | foes = List.concat [ acc.foes, [ foe ] ] }
struck ->
let
hp = foe.hp - List.length struck
survivors = List.filter (\s -> touching foe s == False) acc.shots
in
if hp <= 0 then
{ acc | shots = survivors, hits = acc.hits + 1 }
else
{ acc
| foes = List.concat [ acc.foes, [ { foe | hp = hp } ] ]
, shots = survivors
}
Note the argument order: Mar’s List.foldl hands your function the accumulator first, then the item (\acc foe -> ...), the reverse of some languages you may know. The compiler will usually catch a swap (the types differ), but with same-typed accumulators it cannot, so pin the habit now.
The payoff of the one-pass shape: consumed shots cannot hit two foes, killed foes cannot fire back this tick, every output (survivor lists, score, sound cues) leaves the function in the record, and the whole battle is one pure function you can feed fixtures in a test. When the interactions get truly gnarly, this function is where you aim the time-travel debugger, frame by frame, watching the record evolve.
Resolution order is a design decision
Immutability forces you to choose an order (movement, then combat, then pickups, then spawns) and encode it as the pipeline inside step. Write the stages as named functions and the frame becomes a sentence:
step : Model -> Model
step model =
model
|> movePlayers
|> moveShots
|> battle
|> collectPickups
|> spawnWave
|> cullOffscreen
Whatever order you pick, it is the same on every platform and every run. There is no “physics happened before input this frame because a callback fired early”. The order is the code, and the code is the spec.