Open source · AGPL-3.0
Zolik
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 — and every rule of every one of them lives in a single file on the server.
What it is
Zolik 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 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 Zolik 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 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.
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 |
| 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 at play.limidus.com — the same Docker shape as local development, with the Expo web client exported as a static bundle.
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
Zolik 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 Zolik 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.