A seat map looks like a grid of little squares. RevoTickets needed halls that number their rows right to left, bow toward the stage, and put aisles wherever the venue actually put them. This is the small pure Ruby engine that draws them, and the one rule that took the longest to get right: an aisle that mirrors without moving.
Stage
12345678
One bowed section, one aisle
Responsibilities
Architecture, backend, build
Technology stack
Ruby on Rails 8, Bullet Train, PostgreSQL, Hotwire, Tailwind, SeatchartJS
Year
Status
Pre-launch, GA later in 2026
01 · the setup
Summary
RevoTickets is the ticketing platform I build for Revolution Stage, a live-events company here in Malaysia. It sells reserved seats for shows: real halls, real seat numbers, real money through the bank. This post is about the part everyone underestimates, me included, which is drawing the seat map. The whole thing rests on one small pure Ruby class that takes a section of a hall and turns it into a layout the staff builder, the buyer preview, and the serializer all compute from. No database rows for the picture, no per-row JavaScript. Just seats, a handful of rules, and a number for how much the row bows toward the stage.
I want to walk through why a grid of squares turned out to be the fiddliest thing in the codebase, and the invariant that took me the longest to get right.
02 · looks simple
A grid, until it isn't
The first version of the seat map was exactly what you would guess: loop over the seats, draw a square for each one, next row. It held together right up until a real hall walked in and asked for the things real halls have. An aisle in the middle. A row that starts a little further in than the one above it. A block of seats that isn't sold at all but still has to sit there, holding the shape of the row so the numbering lines up.
The mistake in the naive version was treating everything as a seat. An aisle became a blank seat. An indent became a run of blank seats. It sort of worked, however every one of those fake seats was now something the numbering logic, the pricing, and the reservation code had to remember to skip. Miss one and you would sell someone a ticket to an aisle.
So the engine keeps two ideas apart. A seat is a thing you sell. Everything else about the row's shape is a rule, stored as data, not as a fake seat. Aisles and indents live in one JSONB column on the section, keyed by row label:
A gap of {after: 9, size: 0.5} means a half-seat-wide aisle sits after seat 9. A pad of 2 means the row starts two seat-widths in from the left. That is the entire vocabulary. There is still one thing that has to be a physical placeholder and not a rule, though: the unsold block. RevoTickets calls it a blank seat. It occupies a slot in the row and moves with the row, but it carries no number at all. It is never sold, never priced, never reserved. It is only there to keep the geometry honest.
That single decision, a blank seat has no number, quietly shaped the rest of the engine, because a seat with no number breaks every lazy assumption you might make about ordering.
03 · the aisle that moves
An aisle that mirrors without moving
Here is the problem that ate a whole afternoon. Some halls number their rows left to right. Some number them right to left. The seats and the aisle are physically nailed to the floor in the same place either way, only the numbers on them are painted in the opposite order. So a well-behaved seat map has to be able to flip the numbering direction without the room rearranging itself.
The obvious move is to reverse the row. Take the ordered seats, call reverse, done. Except the aisle is a rule that says "after seat 4", and if you flip the row and keep emitting the gap after the cell for seat 4, the aisle slides one seat over. Flip a hall's direction and every aisle in the building shifts sideways by one. It looks fine in a demo with no aisle. It is completely wrong the moment a venue has one.
The fix is to stop thinking of the gap as a position and start thinking of it as a boundary in numbering order. A rule {after: 4} is the line between seat 4 and seat 5, the way an usher reads the row. Under left to right, placement order is numbering order, so that line falls just after seat 4's cell. Under right to left, placement runs the other way, so the same line has to fall just before seat 4's cell. The seats keep their true numbers the whole time. Only where the gap gets emitted changes.
Left to right · aisle after 4
12345678
Naive reverse · aisle drifted to after 5
87654321
Correct mirror · aisle still between 4 and 5
87654321
Same row, numbered the other way. Reverse the cells and the aisle drifts a seat over. Mirror the boundary and it stays put between 4 and 5.
There is a smaller version of the same trap hiding inside it. A single boundary can carry more than one aisle rule, say a one-unit gap and a two-unit gap at the same spot. Walk them in the same order under both directions and the aisle mirrors as a region but not cell for cell. Emitting them in reverse under right to left makes the mirror total, down to the individual spacer. Nobody would ever notice. I noticed, and it bothered me, so it emits them in reverse.
The pad stays deliberately outside all of this. An indent is where the block physically sits on the floor, not a numbering idea, so a padded row keeps its indent on the same side no matter which way you number it. Renumbering a row must never slide it sideways. That sentence is basically the whole design.
04 · the bow
Bending the row toward the stage
Real seating curves. The back of a section wraps around the stage, so the seats near the edges of a row sit slightly forward of the ones in the middle. RevoTickets gets this from a single integer per section called curve, and turns it into vertical bow with a parabola:
seat_map_geometry.rb · apply_curveRuby
dy = (curve * t * t * 10).round(1)
# t = the seat centre's distance from the row centre, normalised to [-1, 1]
The seat in the dead centre of the row has t = 0, so it stays flat. The seats at the edges have t = ±1, so they bow by the full curve × 10. Everything between rides the curve of t squared. A single-seat row is always t = 0, so it never bows and never divides by a bad number. A curve of zero is flat and skips the whole calculation. It is a tiny amount of maths that gives the map the one thing that makes it read like a room instead of a spreadsheet.
05 · keeping it honest
The boring parts that hold it up
An engine three different screens depend on cannot afford to be fussy about its input. The readers never raise on a malformed rule. A string where a number should be, a negative pad, a non-hash entry, all of it is coerced into shape or ignored. A junk value in one row's rules can slow you down for an afternoon of debugging; it must never take down the render for the whole hall.
Two small choices in there earned their keep. Number parsing is base ten only, so "0x10" is rejected rather than quietly read as sixteen. And the numeric coercion rejects NaN and infinity outright, because a single infinity in one gap size would poison the width sum and push every seat after it to nowhere.
The one that actually bit me is smaller still. The function that reads a gap's anchor number used to have two copies, one in the geometry engine and one in the editor that writes the rules. They drifted apart within a day. One of them sorted an unreadable anchor to the front, the other to the back, so the two writers spent every edit quietly reordering each other's rules. The fix was not clever. One coercion, one owner, made public on purpose so both callers read an anchor exactly the same way. Every writer also runs under a row lock, so two people editing the same section's aisles at once can't clobber each other's change on the way to the same JSONB column.
06 · what I took from it
What I took from it
The thing that looks trivial is where the domain hides. A seat map is a grid until a real venue hands you an aisle, a right-to-left row, and a block of chairs that isn't for sale.
Model the difference between what you sell and what only holds the shape. Separating seats from rules is what made everything downstream stop having to remember exceptions.
Pick the invariant and defend it. "Renumbering must not move the room" is the sentence I checked every change against. It caught bugs I would not have thought to test for.
The next two posts in this little series leave the seat map behind and follow the money, which turned out to be even less willing to behave.