All case studies AI-evaluation benchmark on a production poker engine

FeltBots: a 2005 wire protocol, declared once, spoken by three languages

Java 8 / TypeScript (Bun) / Python / MetaObjects

Three runtimes in three languages share one poker protocol — 155 messages declared once as metadata, 116 of them the 2005 engine's own. The TypeScript codec, the Python bot client and the docs are generated from that declaration, and a test gates it against the engine's source in both directions.

feltbots.com ↗

155
protocol messages, declared once
object.message — a project-registered subtype
2
clients generated from one declaration
TypeScript codec · Python bot client
116 / 155
carry the 2005 engine's command numbers
the other 39 are arena-only additions
both ways
model gated against the engine's source
arena/tests/unit/protocol-message-conformance

The poker engine is production Java from a real-money site that ran 2005–2009. It still deals every hand. Around it sit a TypeScript arena and a fleet of Python bots that are now language models playing for measurement rather than money — and all three have to agree, exactly, on what a poker message means. That agreement used to live in one 239-line constants file and in people's heads. It is now 155 declared messages, and the other two languages are generated from them.

The challenge: the protocol was real, and it was nowhere

A legacy engine's wire protocol is the most expensive kind of undocumented knowledge. It is load-bearing — every seat, every bet, every showdown rides it — and it exists as integers in a constants file, decoded by whoever last touched the code. Add a second language and you copy those integers. Add a third and you copy them again, slightly differently, and the bugs that follow are the ones nobody can reproduce.

FeltBots had exactly that shape: a Java engine that owns all game state, a TypeScript bridge translating TCP to WebSocket for browsers and bots, and Python bots making decisions. Three implementations of one protocol, kept in step by discipline.

The solution: describe what the message MEANS, then generate the rest

The protocol is declared as MetaObjects metadata using one project-registered subtype, object.message — not a fork of MetaObjects, an extension of it, the same move the Wizards of Odd SSE envelope makes. Each message carries what it is for: which direction it travels, when it is sent, what the receiver does with it, and who is meant to see it.

What it deliberately does not carry is the Java wire format. The declaration describes the semantic protocol — what information moves between a player and a table — and encoding, sessions and sentinel values are handled in a later codec pass. The engine is a reference for what each message must carry, not the thing being transcribed.

From that one declaration, owned generators emit the TypeScript codec, the Python bot client, and the protocol documentation.

The money shot: provenance is derived, never restated

116 of the 155 messages carry the original engine's command number. The other 39 are arena-only, invented for the modern platform. You can tell which is which — but there is no @source attribute saying so, and that omission is the design:

# @code PRESENT  -> the message rides the 2005 engine's COMMAND space
# @code ABSENT   -> arena-only, no engine counterpart
# provenance is DERIVED from that, never duplicated into a second attribute

A second attribute would be a second thing to keep true. One of them would eventually be wrong, and nothing would catch it. The same instinct shows up where several messages share a single command number: each payload shape is its own declaration with its own discriminator, and codegen fails closed if two shapes share a number without distinguishing themselves. An ambiguity that would have become a runtime mystery is a build error instead.

What that buys you

  • The two clients cannot drift apart — the TypeScript codec and the Python client are outputs of one declaration, not parallel implementations. There is no second copy to forget.
  • And the engine cannot drift away silently. The Java engine is hand-written and stays that way — but a test parses its protocol source and checks the model against it in both directions, so an invented code fails, and a NEW engine command that nobody modelled fails too.
  • The undocumented becomes documented as a side effect. The protocol docs are generated from the same declaration the code is, so they cannot describe a protocol the code does not speak.
  • A legacy engine did not have to be rewritten. Twenty-year-old Java still owns the game; only the description of how to talk to it moved into the model.
  • Extending the metamodel is a supported move. One registered subtype covered a domain the core had never heard of, with no fork and no patched dependency.

What to read it as

All case studies

Considering MetaObjects for your next platform?

Doug works directly with teams evaluating MetaObjects. Tell us what you're building and we'll tell you, honestly, whether MetaObjects fits.

Email doug@dougmealing.com