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
| Directory | What 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.