Duke capstone 2025
Bet308
The promise we made
The project was a six-week capstone at Duke. Eight people, three Maven modules, JavaFX on the client. The headline deliverable was a card-game sandbox: you could play built-in games, and you could also open an authoring environment to define your own. Rules, turn structure, win conditions, card interactions.
The engine wasn’t the deliverable. The authoring environment was. The engine just had to be flexible enough to let it work.
That sounded like a feature distinction. It turned out to be an architecture decision.
The architecture that almost shipped
V1 was the obvious design. One Java class per game family, each implementing a shared contract:
public interface GameRules {
GameState start(...);
GameState handle(Action action, GameState state);
boolean isOver(GameState state);
}
BlackjackRules was mine. Each variant, open, multi-dealer, triple, chaos, was a small config file the authoring tool wrote out: target score, bet limits, deck count. The rule class read the config and did the rest.
The design had real virtues. Every teammate could open one file and read a game top-to-bottom. Ramp-up was near zero. The authoring tool was simple. Blackjack played; Poker was on the way.
But by the end of Sprint 1 the ceiling was visible. Every new variant needed new Java. Texas Hold’em wasn’t a config tweak; it was a new class. UNO definitely was. And the authoring environment, the actual deliverable, could never be more flexible than the rule class chose to expose. We weren’t shipping an authoring tool. We were shipping a config editor for the games we were willing to write Java for.
I wrote the rule class, and I was the one who saw the ceiling first.
The shift in framing
The realization was one sentence: a game is data, not a class.
If a game is data (JSON, say), the rest of the project gets thinner. The authoring tool just edits data. The loader and writer just translate JSON to and from a model. The validator walks the model and complains about anything inconsistent. The engine runs it. Without that, every one of those tools has to know about every game class and its variant-specific config.
The cost is honest: every error becomes a runtime error, so the validator has to be defensive and read the same vocabulary the engine runs. We’ll come back to that.
The deletion
I argued for it on a Friday afternoon. The team was tired. We had just finished Sprint 1 and the polymorphic engine worked, which is the worst possible time to argue for nuking it.
The case was a trade. Short-term pain, a sprint of rebuild, during which nobody could merge against the engine, for a structurally lower per-feature cost forever after. The number that won the argument was that the extension surface of a JSON-driven interpreter lives in three small registries: actions, card effects, expression functions. A new primitive is one class plus one schema entry. Variants are zero Java. The per-day cost during the rewrite was massive. The per-feature cost after the rewrite was bounded.
We agreed. I spent the weekend doing two things.
First, I wrote down the data shape (model records, loader contracts, registry signatures) before I touched any V2 engine code. I circulated it as a doc on Sunday morning. The point wasn’t documentation. The point was that the other seven people on the team needed to build the player runtime, the lobby, the persistence layer, and the UI against something, and if that something didn’t exist yet, they would build against assumptions. Assumptions are how interface drift happens.
Then I deleted BlackjackRules.java and the 3,000 lines around it. That part was hard.
What V2 actually looked like
The new engine had exactly five step kinds: action, if, while, forEach, and call. An interpreter walked the step tree, pausing when it needed player input. Action steps dispatched through the action registry. Conditions dispatched through an expression-function registry. Card effects rode the same step format as phases, through their own registry. Five step kinds, three registries, one dispatch path.
A phase in the JSON looked like this:
{
"kind": "forEach",
"var": "player",
"in": "players",
"body": [
{ "kind": "action", "name": "dealOne", "to": "$player" },
{ "kind": "if",
"cond": "$player.handValue > 21",
"then": [{ "kind": "action", "name": "bust", "of": "$player" }]
}
]
}
The proof was the second variant. After V2 landed, shipping a new Blackjack variant was zero Java. Drop a JSON file in games/, restart the app, the lobby auto-discovered it. By the end of the project we had shipped seven variants of Blackjack, plus Texas Hold’em, PLO, Ace-to-Five Lowball, UNO, War, and Go Fish. All on one engine.
The abstraction earned its keep
The data-driven engine also made the multiplayer client possible.
The presenter, the JavaFX component that runs a game’s UI, only ever talked to an interface called GameplaySession. Four methods: what’s the current state, are you waiting on input, here’s an action handle it, tell me when state changes.
The local engine satisfied that interface. So did a network proxy that wrapped a WebSocket connection to the game server, forwarded actions over the wire, and pushed state updates back through the listener channel. From the presenter’s point of view, those two were the same thing. The table view never had to know whether the game was local or networked.
I felt the weight of that abstraction the day I wired LLM-generated advice through to multiplayer sessions. In a system where local and networked engines were different beasts, that change would have been two parallel implementations. In ours, it was one path that didn’t care which session type it was running through. The change was small.
An abstraction earns its place the day a wildly different concrete implementation fits the same interface. Until that day, it’s a guess.
The same abstraction made the LLM features possible
Once games were data, “ask an LLM to produce that data” became the obvious feature.
The dialog calls one method: generate(prompt). Inside, the engine runs a pipeline. Prompt the model. Pull a JSON object out of the response. Write it to a draft file. Load the draft through the same loader the rest of the system uses. Run the validator. If validation fails, send the errors back to the model for one repair round-trip. Then either return a fully loaded game or throw.
The load-bearing decision was small. The prompt builder reads the action catalog and the built-in procedure list from the same JSON files the validator and the runtime read. Not a copy. The same files. Add a new action to the engine’s vocabulary and the LLM’s prompt knows about it immediately, because it’s literally the same list.
Prompt-and-runtime drift is how LLM tooling rots. Co-locating the vocabulary makes it structurally impossible.
From everywhere else in the app’s perspective, an LLM-produced draft and a hand-authored one were indistinguishable: same loader, same validator, same engine. The same multiplayer code path that ran a hand-authored Hold’em variant ran an LLM-generated one.
What I got wrong
The authoring UI still asks authors to assemble nested forEach/if/while blocks directly, which mirrors the runtime model too literally. A library of authoring macros, run a betting round, deal and resolve, would have made the panel both smaller and friendlier. I knew it was the right next step. I ran out of sprint.
The other thing: error reporting on malformed JSON is still a deep Java stack trace. The validator catches most issues at load time, but the ones it misses surface as something nobody wants to debug. I would invest there next.
What I actually learned
Two things, both sides of the same idea.
The first is what composition over inheritance actually buys you. V1 was inheritance: BlackjackRules implements GameRules, then a new class for the next game, then another. V2 was composition over a richer data model: an interpreter, three registries, and a JSON DSL the engine ran. The right abstraction lowered the per-feature cost of every later thing. Variants got cheaper. The LLM authoring feature became possible. Multiplayer slotted in without touching the table view. None of those were planned when I argued for the pivot. They all fell out of the same shift.
The second is what an 8-person team running on Agile actually depends on. Stand-ups gave us a rhythm. Sprint retros surfaced friction. Sprint planning forced us to commit to slices we could ship in a week. None of that prevented interface drift: assumptions made in one corner being stale by the time another corner integrated. What prevented interface drift was the data-shape doc I wrote on Sunday before V2, and every contract diff I wrote the same way after that.
Agile gave us the cadence. Written interfaces gave us the shape. The next project I work on, the first artifact will be the contract.