Carom
An Arkanoid, in a terminal, on Cauldron.
A game of it, with the music, is on YouTube.
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 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