|
A macOS app for fielding a race meeting: price the market, sell the tickets, print the dockets, settle the book. It exists to answer one question fast enough to matter: what does this bet cost me if it wins? Prices arrive on their own, all day, from a certain green three-letter betting agency — no login, no key, no scraping a screen. Why · The board · Selling · Money rules · Install · Under the hood |
Fielding a meeting is a speed problem wearing a maths problem's clothes. The arithmetic is not hard. Doing it while a punter is mid-sentence, the horse is shortening, and eleven other people are holding cash — that is the hard part. Every second between "fifty the win on three" and paper in a hand is a second you are not serving the next person.
So the whole app bends toward one thing: get the ticket printed without looking away from the punter. Everything else — the boards, the risk lines, the settlement — is what has to be true around that so the fast part stays safe.
I built it because the alternative is a satchel, a pen and a good memory, and a good memory pays out on a horse that ran third.
Win in lime, place in yellow, one race to a screen — the picture at the top of this page, on a TV turned portrait, which is how nearly every on-course screen is hung. Drag the window onto it and press ⌘F.
Nothing on that screen gives the book away. No overround, no liabilities, no held figures — the punters' screen shows prices and only prices.
The banner carries the book's own name over the track, the race number in its own box on one side and the countdown in a box on the other — the three things a punter crossing the room is trying to read, each in a fixed place so the eye learns where to go rather than re-reading a line of text.
Five things about it are deliberate and cost more work than they look:
The banner is the state's colour, not a brand colour. A book standing at a country meeting runs two screens: the track it is on, and an interstate one. Both were racing green once, so the only thing separating them was the venue name — three words, in a crowd, at fifteen metres. Now NSW is green, VIC red, SA blue, QLD purple, WA gold, no two confusable in daylight, and an unrecognised location gets a flat charcoal rather than borrowing a colour that means a state. If the banner comes out pale, the header ink flips to black on its own.
Every runner is always on screen. Row height comes from the window height divided by the field size, so a 6-runner race and a 24-runner race both fill the display and neither scrolls. What it does not do is scale text per row — one horse's name coming out smaller than the next one's is worse than a truncated name, so long names truncate.
Type is sized to the column it goes in, not just to the row. Sizing from row height alone is a guess about whether the text fits, and on a tall narrow screen the guess was wrong: 96pt prices in a 195pt cell, so every four-character price truncated to 1..... Every size is now min(what the row allows, what the column can carry), measured in ems and rounded down to whole points — and the check suite asserts it across seven pane shapes and five field sizes, so a price that would clip fails a test instead of reaching a television.
A scratching cannot be read as a price. The row goes red, the name is struck through, and both price cells say SCRATCHED rather than showing anything a punter could bet on. It keeps its place in the card, because someone counting down the numbers has to find 6 crossed off, not find 6 absent.
It shows the next bettable race, not the one being run. A race being run belongs on the television. Holding it on the price board just costs the next market its betting time. A result lingers seven minutes from the jump so the crowd can read it, then the board moves on by itself — without dragging the operator off a race they are still paying out on.
The stake and saddlecloth boxes take digits and nothing else. That is not input validation, it is what makes the keyboard work: since a letter can never be data, a letter is free to be a command. W, P and E sell and print the instant you press them. S is a split — different win and place amounts — so it only moves the cursor to ask for the second number.
Every key is printed on its own button. You never have to remember one, and you never have to reach for the mouse to use one.
A winner does not settle the race. The other place positions decide whether a rival's money gets paid too, and the horse carrying the book's biggest place liability may or may not sneak into third — which is not knowable when the bet is taken. So every runner carries two figures, not one:
| The finish it assumes | |
|---|---|
| If wins alone | This runner home, cheapest others in the places — normally horses with nothing on them |
| Worst if wins | The same winner, costliest others in the places |
They are the range that winner can land in, and where they agree the second is dimmed, because then it is one fact printed twice. The risk budget and the OVER LIMIT alarm measure the harsh one — a risk gate must never quietly start measuring the kind number. The best and worst case chips are the ceiling and the floor of those two columns.
A single figure had to pick an end and be silently wrong about the other. It picked the harsh one, so a book holding a thousand dollars each-way on one horse reported every possible finish as a loss — when that horse running fourth is worth +$1000.
Sell four ways on one keystroke. Win, place, win-and-place at equal stakes, or a split at different stakes. Every bet is a (win, place) pair underneath, so there is one settlement path and not four.
Frame the market yourself. Nudge a price two rungs under the feed and it stays two rungs under as the feed moves all day — because a bookmaker means "under the market", not "at 4.50". Or type a price and freeze it. Take a whole race off the feed and price the lot by hand.
Keep trading when the feed dies. No prices means you type them. There is deliberately no failover to a worse source, because a wrong price is worse than no price. When the feed comes back it takes the board back automatically — but only for races that went manual because of the outage, never one you chose to frame yourself.
Run up to four TVs at once. Each screen follows its own venue and moves through that card by itself, or gets pinned to one race for the whole day — the Cup up on one screen while another works through the rest of the meeting at the same track. A pinned screen ignores auto-follow and stays put even if the venue comes off the rail.
Watch the exposure move. Per-runner liability, worst case, best case and your top-liability runner all update on every ticket, and the row shades red as it gets expensive. There is a risk budget that scales with the day's actual P/L rather than sitting at a number you picked this morning.
Settle itself. Results land, winners are paid, refunds handled, dead heats split. A protest re-settles and writes an adjustment row for anyone already paid, rather than quietly rewriting a payment that already went over the counter. A wrong result can be unresulted: unpaid tickets go back to open, nothing settles the race again (not even the feed) until you enter the correct placings, and anyone already paid gets an adjustment row.
Field a meeting nobody publishes. Picnic and bush tracks that no feed carries at all: type the field in, price it, sell it, settle it. Same code path as any other race.
Print on real paper. Any USB receipt printer, over raw USB — no CUPS driver, no print dialog, no lpr. Standard USB printer-class devices are found on class alone, whoever made them; Epson's TM series and the other common POS brands hide behind a vendor-specific interface and are recognised by vendor. Two printers plugged in, or an unusual one? Pick it by hand in Manage. Plug it in and it prints.
Reconcile at the end. Turnover, payouts, what is still in punters' pockets, and a day sheet you can pin to the bag. The unpaid list and the day's outstanding figure must agree, and if they ever don't, that is a bug you want to hear about immediately.
These are the parts that cannot be wrong. Every one is pinned by an assertion in the check suite.
Place terms lock at the sale. Under 5 runners pays no places, 5–7 pays two, 8+ pays three — and whatever was true when the docket printed is stored on the ticket. Settlement pays max(terms sold, terms final), so a punter is never worse off than the paper in their hand.
A late scratching is a deduction, never new terms. Any scratching after the sale returns the stake in full and cuts only the profit. Moving the terms instead would rewrite a contract that is already in someone's pocket. Deductions apply to tickets sold before the scratching landed; the ones after it were priced on the smaller field already.
Scratchings are never scraped. A guessed scratching is a wrong refund. You scratch by hand — right-click the runner — and it persists across polls and restarts.
Dead heats split properly. N runners dead-heating for S positions means each backer wins S/N of their stake at full odds and loses the rest. Not the full stake. This is the one that is a money bug rather than a display bug.
Settlement is idempotent, in SQL. Double payment is blocked by a WHERE paid = 0, not by a hopeful if. The race result is written once and a second attempt is a no-op.
Payouts round up to 5c, always the punter's way. Nobody counts single cents across a counter, and a house that rounds its own way on every ticket is quietly skimming.
Ticket numbers are plain integers from 1, forever. A punter shouts their number across a crowd. It has to be sayable.
The odds ladder is written out by hand, and that's on purpose
The obvious implementation is a set of from/to/step bands: 1c steps at odds-on, 5c to evens, 10c to 2/1, and so on. It is also wrong, because the real ladder is irregular at both ends:
1.10 → 1.12 → 1.14 → 1.15 → 1.16 … 1.24 → 1.25 → 1.26 1.15 and 1.25 break a 2c run
21 → 23 → 26 → 27 → 31 … 81 → 91 → 101 27 and 91 break the pattern
No arrangement of bands reproduces that, and a generator that came close would quietly price runners onto rungs that don't exist. So the table is written out verbatim — 106 rungs — and the table is the specification. It is also the calibration knob: adjust it there and win prices, place prices, nudges and snapping all follow.
Place prices move in probability space, not price space
Shorten a runner's win price and its place price has to follow. The tempting move is to scale the place price by the same proportion. That is badly wrong for favourites.
Place probability does not respond proportionally to win probability. A 40/1 shot that firms to 30/1 improves its place chance by roughly the same proportion — but a horse already 90% certain to place cannot gain much no matter what you do to its win price. Scaling proportionally slashes a favourite's place price every time you touch it.
So the market's own place price stays as the anchor, and it moves by exactly the ratio a model says the place chance changed by — a Discounted Harville model with a Henery discount of λ = 0.76, run twice over the same field, once on the feed's win prices and once on the board's. The ratio is taken on fair odds rather than bookmaker odds, because moving any price changes the overround, and a bookmaker ratio would smear that shift across every runner and report horses moving when their chances hadn't.
Command Line Tools only. Full Xcode is neither needed nor used.
xcode-select --install # if you haven't already
git clone https://github.com/RatterAU/Bookie.git
cd Bookie
make mock # synthetic fields and prices, no networkMock mode is a complete working book — sell into it, settle it, pay tickets out, none of it touches anything live. When you want the real thing, make app and open Bookie.app.
Important
Bookie.app is a snapshot of whenever make app last ran. It does not update itself, and the app will tell you in the title bar when the source has moved on without it.
All the make targets
| Command | What it does |
|---|---|
make run |
Run from source against live data |
make mock |
Run against the mock feed — no network, safe to experiment |
make test |
The assertion suite (bookie-check) |
make app |
Release build, packaged into Bookie.app |
make open |
make app, then launch it |
make clean |
Wipe build products and the bundle |
Printers: what works, and how one is found
There is no CUPS queue, no driver and no print dialog. The app opens the printer's USB interface itself and writes ESC/POS to the bulk-OUT endpoint, which is why a ticket prints on the keystroke instead of after a spooler thinks about it.
Two kinds of printer answer:
| Interface class | Who | How it's found |
|---|---|---|
0x07 — USB printer class |
Most 80mm receipt printers | On class alone, any vendor, no list to be on |
0xFF — vendor-specific |
Epson TM series, and the common POS brands | By vendor id — Epson, Star, Bixolon, Citizen, SNBC, Custom, Zjiang, Rongta and the generic Winbond/STMicro boards |
Vendor-specific interfaces are not self-describing — USB-serial adapters and debug interfaces live at 0xFF too — so those are taken from the known list rather than written to blind. If yours isn't on it, or two printers are plugged in, Manage ▸ Printer lists what's connected and pins one by vendor and product id, which survives a replug and a reboot.
Paper is 80mm, Font A, 40 printable columns.
Why there's no SwiftData, no @Observable and no XCTest
Every one of them needs a macro or test plugin that Command Line Tools alone cannot load. Rather than require a full Xcode install to build an 11,000-line app, the project does without:
| Instead of | It uses |
|---|---|
SwiftData / @Model |
Raw SQLite3 through a thin wrapper (DB.swift) |
@Observable |
Combine ObservableObject |
| XCTest / swift-testing | A plain executable of assertions that exits non-zero on the first failure |
This is a constraint the codebase is built around, not a limitation it apologises for. It also means make test runs in about a second.
Name yourself. A prompt asks once for the bookmaker name. It prints across the top of every ticket and heads every TV, so the paper in a punter's hand and the screen they read the price off cannot disagree. A docket without a name on it is not a docket anyone can bring back.
Pick your venues. The rail starts empty on purpose. It is an allow-list, not a deny-list — the card sources sweep the whole country, and a deny-list would silently walk fifty tracks onto your screen the moment one of them published late.
Sell. Stake, runner number, then a letter.
⌘1 |
Selling | Field, prices, stake entry, live risk |
⌘2 |
Price board | Punter-facing · one window per TV, up to four |
⌘3 |
Manage | Tickets, payouts, the book, day sheets, history |
⌘4 |
Vision | Race broadcast beside the selling screen |
⌘⌥1…⌘⌥4 |
Follow a TV | Write tickets for the race on that screen (⌘⇧ is macOS screenshot, so it isn't used) |
W |
Win | sells and prints on the keystroke |
P |
Place | sells and prints on the keystroke |
E |
Win place | equal both legs, sells and prints on the keystroke |
S |
Split | different amounts — focuses the place box for the second number |
⌘Z |
Void | takes back the last sale, while the race is still open |
Cards come from several places and prices come from one. They are assembled per venue rather than merged, because a race id is source-specific and a sold ticket has to stay bound to the card it was written against.
flowchart LR
FEED["Price feed<br/><i>green, three letters</i>"] --> RAIL
RA["Racing Australia<br/><i>cards only, never prices</i>"] --> RAIL
HAND["Hand-entered<br/><i>bush + picnic tracks</i>"] --> RAIL
RAIL(["SourceSelection<br/><i>pure · tested</i>"]) --> ENGINE
ENGINE["AppEngine<br/><i>@MainActor · single source of truth</i>"] --> LADDER{{"Odds ladder<br/>+ margin + overrides"}}
LADDER --> BOARD["TV boards"]
LADDER --> SELL["Selling screen"]
SELL --> STORE[("TicketStore<br/><i>every cent that moves</i>")]
STORE --> SETTLE["Settlement<br/><i>idempotent</i>"]
STORE --> PRINT["ESC/POS over USB<br/><i>any receipt printer</i>"]
Four decisions carry most of the weight:
- One venue, one key. Sources shout their venue names differently —
MORPHETTVILLEfrom one,Morphettvillefrom another, whatever was typed for a hand-entered card. Three spellings put the same meeting on the rail three times, so every venue lookup in the app goes through one canonical key of uppercase alphanumerics. - A stale source choice falls back rather than emptying the rail. Pick a source yesterday, have it not carry the venue today, and the rail must still show the meeting off whatever does carry it. Likewise a per-race override that the other source cannot answer is ignored rather than dropping the race — dropping a race takes its betting with it.
- The engine is the only source of truth. Views read it and call it. No view holds its own copy of the day, and no view does money arithmetic.
- History rebuilds from stored cards, not from the feed. The moment money goes on a race, its card is snapshotted into the database. That snapshot is what a past race reopens from — field, prices sold, liabilities, result — long after the feed has dropped the day, or for a bush meeting that was never on a feed at all.
Things that only show up on a real race day
Most of the awkward parts of this codebase exist because something behaved differently at a track than it did at a desk.
| What's actually true | What the app does about it |
|---|---|
| The racing signal can trail the actual jump by up to ~45 seconds, and races run late besides | Polling goes hard from 90 seconds before the scheduled jump until five minutes after it, rather than giving up on time — giving up early is what leaves a board reading OPEN on a race already past the post |
| A price board that stops updating looks exactly like a price board that agrees with you | The age of the last successful update is shown next to the feed badge, and a price that has gone stale is labelled with how old it is instead of sitting there looking live |
| A one-second countdown on the shared engine republished every window once a second — every silk, both boards, every runner row, 60 times a minute | The clock is a separate object that only the handful of views showing elapsed time observe |
| Silk images are PNGs with white backgrounds | The board uses hard near-black rules between rows rather than alternating row shading, which would make every silk look like it was floating in a box |
| A meeting can be abandoned with money already on it, and an abandoned race never gets placings | Abandonment is settled explicitly and refunds the race, rather than leaving every ticket open forever waiting for a result that is not coming |
| Someone will scratch a horse on a race you are not looking at | Scratchings are recorded from every race on screen and once more as a race settles itself — with no record, settlement applies no deduction and the whole race pays out on the old, bigger field |
Source layout
Sources/
BookieCore/ no UI, fully testable
Models.swift Race, Runner, Ticket, bet types
DB.swift raw SQLite3 wrapper
TicketStore.swift every cent that moves
Pricing.swift odds ladder, margin engine, place model
SourceSelection.swift which source answers for which venue (pure)
BoardPicker.swift which race a TV shows (pure)
Printer.swift ESC/POS over raw USB, any receipt printer
BoardLayout.swift every width and type size on a TV (pure)
Reports.swift · DaySheet.swift · StateColours.swift · Format.swift
Bookie/ SwiftUI
AppEngine.swift @MainActor, single source of truth
SellingView · BoardView · ManageView · ResultsView
HistoryView · UnpaidView · WatchView · CustomVenueView
bookie-check/ assertions — money paths live here
Real output, not a mock-up. Every outcome spelled out in words and dollars, because a punter should never have to do arithmetic to know what they are holding:
J. SMITH RACING
========================================
MUSWELLBROOK
RACE 1
========================================
TICKET 1
========================================
#3 ZORKO
$50 WIN PLACE
----------------------------------------
$50 WIN @ 4.50
$50 PLACE @ 1.70
3 PLACES PAID
----------------------------------------
IF WINS PAYS
$310
IF PLACES ONLY PAYS
$85
----------------------------------------
26 Jul 2026 10:24:46 am
RETAIN TICKET TO COLLECT
----------------------------------------
GAMBLE RESPONSIBLY
Think. Is this a bet you can afford
to lose?
Gambling Help 1800 858 858
gamblinghelponline.org.au
18+ ONLY
Print one without a printer attached:
BOOKIE_RECEIPT=1 swift run bookie-checkThe price is quoted the way the board called it, and the responsible-gambling footer is covered by an assertion so it cannot quietly disappear in a refactor.
make test # 635 assertions, no network
BOOKIE_LIVE=1 swift run bookie-check # opt-in: hits the real endpointsThe suite is aimed squarely at the money: place terms, deductions, dead heats, refunds, abandonment, double-pay protection, re-settlement after a protest, unresulting and re-entering a result, and the reconciliation between the unpaid list and the day's outstanding figure. Touch settlement, add a check.
| What | Where |
|---|---|
| Tickets, results, adjustments | ~/Library/Application Support/Bookie/bookie.sqlite |
| Hand-entered meetings | ~/Library/Application Support/Bookie/custom-meetings.json |
| Exported day sheets | ~/Documents/Bookie Day Sheets/ |
All of it local. No account, no backend, no telemetry, nothing uploaded anywhere.
Warning
If the ticket database cannot be opened, the app says so in red and runs in memory rather than pretending. Tickets will not survive a relaunch, and you will be told that rather than discovering it.
Not a betting platform, not an exchange, and not a way to take money over the internet. It is the software half of a satchel: it prices, it sells, it prints, it settles, and it tells you what you are carrying.
It is also not a licence. Bookmaking is a licensed activity in every Australian state, and complying with your regulator's rules on pricing, record-keeping and payouts is entirely yours. This project is not affiliated with, endorsed by, or connected to any wagering operator, racing body or broadcaster; it reads public endpoints the same way a browser does.
Card data © Racing Australia. Code is MIT licensed — see LICENSE. Contributing? CONTRIBUTING.md is the short list of invariants this codebase will not bend on.
Gambling Help 1800 858 858 · gamblinghelponline.org.au · 18+
Built for the rails, not the boardroom. 🇦🇺



