Current section
Files
Jump to
Current section
Files
README.md
# Carom
An Arkanoid, in a terminal, on [Cauldron](https://github.com/jaman/cauldron).
[](https://www.youtube.com/watch?v=6l3q_5xSbLI)
A game of it, with the music, is [on YouTube](https://www.youtube.com/watch?v=6l3q_5xSbLI).

```bash
mix carom
mix carom --mode text # glyphs, even where the terminal has pixels
mix carom --fps 120 --seed 7 --name alice
```
**Move the paddle with the mouse.** `space` or a click to serve, `p` to pause, `r` to restart,
`Esc` or `q` back to the title. `←/→` also work, with the caveat below. `s` opens the
client's settings — key bindings, pointer steering, effects and music levels, display, frame
rate — and `?` shows the keys in play.
The game is a `Cauldron2D.Game` (`Carom.Game`) that `Cauldron2D.World` runs at the frame
rate, and a `Cauldron2D.Client.Game` (`Carom.Client`) that `Cauldron2D.Drafter.Client` puts
in front of the player: title, the field, settings, sound and music. There is no lobby;
`Enter` on the title starts a world of the player's own.
## Why the mouse
A terminal sends no key-up event. An application therefore cannot tell a tap from the start of
a hold until the first auto-repeat arrives — and an OS does not begin repeating for around half
a second. Every keyboard scheme is a choice between the two ways that goes wrong:
* stop when the press stops mattering, and a held key moves the paddle briefly, stalls for the
rest of that half second, then runs — the stutter
* keep going, and the paddle coasts after you let go
`:paddle_lead` is that dial, live on `,` and `.`: it is how far a single press glides. Small is
precise and stutters; covering the repeat delay takes about seven tiles of glide, which is a
third of the field.
The mouse has no such problem, because motion is reported continuously as it happens, and a
paddle following a pointer is the original control for this game anyway. `Carom.Game.aim_at/2`
puts the paddle where the pointer is with no easing; the keyboard's `nudge/2` and `advance/2`
remain for anyone without one.
The pointer is mapped **absolutely** — `Cauldron2D.Camera.origin/3` and `tile_at/2` turn a screen cell
into the world tile under it. Accumulating deltas instead cannot recover: the moment one is
dropped, or the paddle clamps against a wall while the pointer keeps travelling, the two are
permanently out of step. In text mode the paddle lands within half a cell of the pointer, which
is as close as a six-cell-wide paddle can get to a cell's centre; in pixel mode it is exact.
Where the terminal reports key releases (the kitty keyboard protocol: kitty, Ghostty,
WezTerm), the client holds `←`/`→` for exactly as long as the key is down and the paddle
moves at its speed until it is let go; elsewhere `Cauldron2D.Input` holds a press through
the terminal's repeat delay, which is the glide.
## Levels and powerups
Five hand-drawn layouts, cycling, with the ball a little faster each time round. A layout is
written as a picture — one character per brick — so a level is designed by looking at it rather
than by working out what `rem(column + row, 5)` produces, which is what the first version did
and why all three of its levels looked like noise.
c soft 10 pts s solid: bounces, never breaks, never blocks a clear
b firm 30 pts . empty
a hard 50 pts
Solid blocks are the useful part for design — a roof, a pair of pillars, a funnel — structure
the ball has to work around rather than something the player simply deletes.
Breaking a brick has a 1-in-7 chance of dropping a capsule:
| | |
|---|---|
| **wide** | a paddle half again as long, 18 s |
| **slow** | the ball drops to 65% speed, 14 s |
| **multi** | every ball in play splits into three |
| **life** | an extra one |
| **narrow** | *bad*: a stubby paddle for 10 s — same pill, different colour, so it can be dodged |
Wide and narrow replace each other rather than stacking, and drops come from a seeded
generator carried in the game state, so a whole rally replays exactly from its seed.
## Why it exists
To be a second game of a **different shape** from the first. [Scriber](https://github.com/jaman/scriber) is
turn-based, grid-aligned, and redraws when a key is pressed. This is real-time, moves *between*
tiles, and redraws sixty times a second whether or not anything happened.
Those were exactly the two things the engine had never been asked for, and they were the two
open questions in its design. Building this answered both:
**A fixed timestep** (`Cauldron2D.World`). A frame arrives whenever the terminal, the scheduler and the
app loop agree to give one; if the world advanced by "one frame" the ball would move faster on
an idle machine and tunnel through bricks on a busy one. The world advances in fixed steps and
a frame runs however many have come due — with a cap, so a long stall is dropped rather than
replayed into a spiral.
**Sub-tile movers** (`Cauldron2D.Surface.compose/3`). A ball at tile 4 is a brick game; a ball at
x=4.37 is Arkanoid. The cached tile compositor is fast precisely because everything is aligned
to a fixed stride, and a sprite at a fractional offset is not — so movers are a separate,
deliberately small path that touches only the rows they land on. Measured on a full field:
**565 µs** to compose the grid, **609 µs** with the ball and the three paddle segments on top.
The feature costs about 44 µs and a game with no movers pays nothing.
## What it found
Two bugs, both of which would have been much more annoying later:
* **Events were being cleared per step.** A frame runs *several* steps, so only the last one's
events survived — the brick you broke two steps ago would have made no sound. They now
accumulate until `drain_events/1`, which is called once a frame.
* **Everything outside the playfield read as wall**, and since a pane is usually larger than
the field, the border came out three tiles thick instead of one.
## Layout
```
lib/carom/
game.ex the whole game as one value and a Cauldron2D.Game; step/2 advances it by
dt, and nothing here reads a clock or touches a terminal
client.ex the Cauldron2D.Client.Game: the arena, the scene, the hud, keys, sounds, music
tuning.ex the settings as a Cauldron2D.Tuning
art.ex the tiles, drawn in code, installed as an atlas
sound.ex the voice of each event
music.ex a piece a level, as one Cauldron2D.Audio.Music piece
levels.ex the layouts
```
`Carom.Game` has no dependency on `drafter` or a terminal — a whole rally is a fold of
`step/2` over a list of durations, which is how the physics is tested: constant speed
regardless of step size, no tunnelling at 400 tiles/second, and a 3600-step rally that never
leaves the field. `Cauldron2D.Test` drives it as the world would, input and all.
## Tracing
`CAULDRON_TRACE=1 mix carom` writes the engine's trace for `mix cauldron.report`;
`DRAFTER_TRACE=1 mix carom` writes drafter's input trace for `mix cauldron.input`, which
measures the stdin path and the writes. `mix carom.inspector` measures pointer report timing
from drafter's input debugger log.
## License
MIT