← All projects

Open source · AGPL-3.0 · formerly Zolik

Jokerless

One backend computes every rule. The clients hold none.

A card-game server that does not need to know which card game it is running. Four games run today on one runtime, one wire protocol, one screen client and one terminal client — every rule of every one of them lives in a single file on the server, and any finished match can be replayed, hand by hand.

What it is

Jokerless is a card-game server that does not need to know which card game it is running, because each game's rules are self-contained — and two clients that need to know nothing about any of them, because the server hands them finished answers.

Four games are hosted today — Žolíky (Continental rummy), Prší (Czech Mau-Mau), Canasta and No-Limit Texas Hold'em — and they share one runtime, one wire protocol, one screen client and one terminal client. Adding a fifth is a server-only change: register a module and it appears in the lobby, gets an opponent, gets a scoreboard, and is playable in a browser and over SSH without either client being edited.

The project used to be called Zolik. The engine, the source repository and the underlying package names still say zolik — that has not changed — but the product is now Jokerless, live at jokerless.com and jokerless.org. The original play.limidus.com address still serves the same deployment, so old links keep working.

The module contract

A game implements module.GameModule, which asks it to do four things:

  • Deal a match — set up the initial state.
  • Apply an action — advance the state given a legal move.
  • Describe the board as zones — the piles, hands and melds a particular viewer is allowed to see.
  • List the offers — the moves that particular viewer may take right now.

It is registered in internal/app/app.go, and that is the whole integration. Everything else belongs to the runtime and is written once: persistence, the socket, turn order, bots, reconnection, statistics, and both user interfaces.

The zones-and-offers pair is what keeps a client purely presentational. A zone answers "what does this player see"; an offer answers "what may this player do". Both are computed by the module that owns the rules, so the client renders answers instead of working them out.

One rulebook, one implementation

The rules of a game are written once, in that game's module, and nowhere else. The Go backend resolves every rule-derived fact — whose turn it is, which moves are legal, what a card may be played on, whether the round has ended, what it scored — and sends the finished result to whoever is watching. Neither client contains a line of rule logic.

That is what makes the whole thing efficient in the way that matters. The work happens once per action, on the server that already holds the authoritative state, rather than being repeated in every connected client. A client never has to ask "is this allowed?" and wait for an answer, because the answer arrived with the state. And a rule that needs correcting is corrected in one file — not chased across a Go server, a React Native app and a terminal client, hoping all three end up agreeing.

Every rule-derived fact is computed once, on the server, inside the module — and shipped.

Drag-and-drop, without a rulebook in the client

Dragging a card is where this pays off most visibly, because it is the feature that normally forces a card client to reimplement the rules: may this card go on that meld, does this pile accept it, is it even my turn.

In Jokerless the client is never asked. Each offer it has already been given names the cards it takes and where they land, so the set of legal drop targets is a filter over that list — moves the server has already validated. The gesture is presentation, not adjudication. It arrives free with the module, in both clients, for every game, and it behaves identically everywhere because there is only one thing deciding. The poker table works the same way: the board is dealt onto the table beside the deck, and the No-Limit Hold'em showdown is a moment the client can pause on and show again, not just a number appearing in a log.

The terminal client is the continuous proof. It plays the same four games against the same server over the same protocol with an entirely different renderer — so anything rule-shaped that leaked into the graphical client would show up immediately as a divergence between the two.

Replay: step back through any finished game

Once a match ends, it is not gone. Jokerless can fold the same append-only action log that drove the live game back into every board it ever passed through, and serve the whole thing back for stepping through — because every module's Apply is pure, the deal plus the log is the game, and a replay is a fold rather than a recording. Nothing needed to be added to a match to make this possible; the history was always being kept.

  • Every hand, face up. A finished game has nothing left to hide, so a replay opens every player's cards — the one view nobody gets while the match is live.
  • Tracks, not just frames. A long match is unreadable one step at a time, so the server marks which frames belong to which seat and which belong to round boundaries — one track per player, plus the rounds — so you can follow just your own moves, just one opponent's, or the whole thing.
  • Jump to the deal you're looking for. A chapter strip names each round in the game's own words — a deal in Žolíky, a hand in Hold'em — so finding "that one hand" is a click, not a long step-through.
  • Mark where you were. Drop a bookmark while watching and find your way back to it later. Marks live on your device only — a note about where you were looking, not a fact about the game, and no module ever knows they exist.
  • Seated players only. A replay is served over an authenticated endpoint to the people who were actually in the match — unlike the public spectator view, which only ever shows one board and never the whole history.

Replay is gated behind the deployment actually being able to keep its word: the store has to be KDB, and the namespace has to be retaining full history, checked on every call rather than assumed once — because a namespace that stops retaining history mid way through would otherwise make a replay quietly wrong instead of simply unavailable. It is one more thing that KDB's document history buys for free once a product asks for it.

The pieces

DirectoryWhat it is
server/The runtime and the game modules — REST, WebSocket, MongoDB, and an embedded SSH host
client-react-native/Expo GUI client for web, iOS and Android — the Jokerless web app
client-tui/Text-only terminal client, built on Bubbletea, reachable over SSH
e2e/Playwright specs that drive the real server and the real web build

The terminal client is not a toy — it is a full second front end over the same protocol, and the reason a rule can never quietly settle into the graphical client.

Running it

./scripts/dev-stack.sh up     # Docker server (:8090) + web client (:8114)
./scripts/dev-stack.sh test   # every suite: Go, terminal client, RN, e2e

Production runs the KDB single-container stack on our own hardware behind nginx, answering at jokerless.com (canonical), jokerless.org and play.limidus.com — one deployment, one image, three names. The web client calls whichever domain served it, so each name is a complete, independent copy of the game and sign-in is per domain.

APP_ENV=production is the deployed setting, and it is a real switch rather than a label: it turns off the SSH terminal client, its admit-any-key mode, and both development hatches, and it makes the SMTP_* variables required. The server refuses to start without a mail host rather than swallowing sign-in codes, and the deploy script checks for that combination before it builds anything — the alternative is a container that fatals on startup and is restarted forever. Guest-only play with no mail server is APP_ENV=local.

Knowing what is running

Every running piece — the server and both clients — reports a build like 1.1.1.2+7feb025: a number plus the short commit it was built from, so "is the fix in?" is answerable by looking at the screen instead of reading logs.

The VERSION file holds the three-part number, bumped by hand for a real release. The fourth part is generated — commits since VERSION itself last changed, so a bump resets it to zero. A build made from an uncommitted tree gets -dirty appended. scripts/version.sh is the one place this is computed, and the dev stack, the npm scripts and the Docker build all read it from there, so the number and the hash cannot disagree between pieces for any reason other than actually being different builds.

The server exposes its build at GET /version. The React Native client shows both halves in a footer on the main menu; the terminal client shows its own in the header, and names the server's too — but only when it differs.

Licence

Jokerless is licensed under the GNU Affero General Public License v3.0, the same licence as KDB.

That is not a stylistic choice. The server links KDB directly — internal/db/kdb.go imports its embed, storage, codec, schema, auth, server and document packages, and Go statically links them into the binary. The resulting server is a derivative work of an AGPL-3.0 engine, and Jokerless is served over a network, which is exactly the situation the AGPL's remote-interaction clause addresses.

Read further

  • Architecture — the original review, and what had to change.
  • Extensibility plan — phases 0–4: the offer protocol, the module contract, and the second game that falsified it.
  • Canasta plan — the third module, and why a second rummy was not a configuration problem.
  • One-architecture plan — phases 5–8: poker, bots for every game, one client shell, and the deletion of the Žolíky-specific path.

The source repository is still named zolik on GitHub — only the deployed product and domains were renamed to Jokerless.