Carom

An Arkanoid, in a terminal, on Cauldron.

Carom in play, with its music

A game of it, with the music, is on YouTube.

Carom: the ball in flight over five rows of bricks, the tuning and score beside the field

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:

: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 absolutelyCauldron2D.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:

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