A compact TCP game server in Go, built on zinx: device / Google / Apple / token authentication with JWT, one live session per account enforced through Redis, MongoDB-backed player accounts, chat rooms and an interceptor chain that guards the authenticated part of the protocol.
$ make up
smoke test against 127.0.0.1:8999
[PASS] ping - pong
[PASS] auth by device - msgId=1002
[PASS] auth returns a jwt - token=165 chars
[PASS] auth returns the account
[PASS] authorized request (account) - msgId=1100
[PASS] re-auth on a new connection
OK - the stack is healthy (mongodb + redis + tcp game server)
- Binary protocol design over raw TCP: an 8-byte TLV frame (
msgId uint32 | length uint32, big endian) with JSON payloads, so a game client can be written in any language in a few lines (scripts/smoke.pyis a full client in ~90 lines of Python). - Message-id routing instead of HTTP paths: ids below
1100are public (ping, login), everything above runs through an interceptor that requires a validated session. - Single device per account: the
connIdof the live session is kept in Redis. When the same account authenticates from a second socket, the first one is closed withanother device login errorinstead of silently forking the player's state. - Identity linking: the same account can be reached by device id, Google id or Apple id, and a previously issued JWT can be exchanged for a fresh token.
- Operational basics:
/app/healthcheckbinary + containerHEALTHCHECK, docker compose stack with real health-gated startup order, a cron-driven CCU gauge, and a CI pipeline that boots the stack and runs an end-to-end smoke test against it.
flowchart LR
C[Game client<br/>Unity / Cocos / custom] -->|TCP :8999 TLV frames| S[zinx server]
S --> I{interceptor<br/>msgId >= 1100}
I -->|no session| R[RpcError + close]
I -->|ok| H[handlers]
H --> AS[auth service]
H --> US[user service]
AS --> U[(MongoDB users)]
US --> U
AS --> RS[(Redis sessions)]
RS --> I
Every frame is msgId(uint32, big endian) | length(uint32, big endian) | payload(length bytes).
| msgId | name | auth | payload | response |
|---|---|---|---|---|
| 1000 | ping | no | anything | pong (raw) |
| 1001 | auth by token | no | {"id":"<jwt>"} |
{"token":...,"data":{...}} |
| 1002 | auth by device | no | {"id":"<device id>"} |
same, account created on first call |
| 1003 | auth by Google | no | {"id":"<google id>"} |
same |
| 1005 | auth by Apple | no | {"id":"<apple id>"} |
same |
| 1006 | join chat room | no | {"id":"<room>"} |
room state |
| 1100 | get account | yes | {} |
account document |
| 9999 | error | - | {"code":1000,"message":"..."} |
- |
Error codes live in internal/utils/error_util.go (1000+, plus 9999 for the error frame).
Requires Docker with the compose plugin.
git clone https://github.com/lasttoss/zinx-server.git
cd zinx-server
make up # builds, starts mongodb + redis + server, then runs the smoke testAlready using :6379, :27017 or :8999 for something else? Copy .env.example to .env and
change REDIS_PORT, MONGO_PORT, SERVER_PORT (make up and make smoke both read it).
make logs # follow the server log
make down # stop the stackLocal development without Docker for the app itself:
make infra # mongodb + redis only
make run # go run ./cmd/server (config.yaml points at localhost)
make test # unit testsmake race # go test -race ./...
make coverage # prints the total coverage lineUnit tests cover the JWT lifecycle (subject + expiry, expired token rejected, foreign signature
rejected, garbage rejected) and the api error encoding. The integration path
(ping → auth → authorized request → session enforcement) is exercised by scripts/smoke.py,
which CI runs against the compose stack.
config.yaml holds development defaults; every key can be overridden from the environment with
the MYAPP_ prefix (dots become underscores), which is how docker-compose.yml points the
server at the mongodb / redis services.
| config.yaml | environment | default |
|---|---|---|
database.url |
MYAPP_DATABASE_URL |
mongodb://root:devpassword@localhost:27017 |
database.name |
MYAPP_DATABASE_NAME |
zinx |
redis.url |
MYAPP_REDIS_URL |
redis://localhost:6379/1 |
jwt.secret |
MYAPP_JWT_SECRET |
dev-secret-change-me |
google.client_id |
MYAPP_GOOGLE_CLIENT_ID |
empty |
apple.client_id |
MYAPP_APPLE_CLIENT_ID |
empty |
conf/zinx.json (zinx's own file) holds the listen address, connection limit and max package
size; the port is 8999.
cmd/server entry point
cmd/healthcheck tiny TCP probe used by HEALTHCHECK / orchestrators
internal/configs viper config + mongo/redis clients
internal/routers route table (msgId -> handler) and the connection lifecycle hooks
internal/filters interceptor: session check for msgId >= 1100
internal/handlers one handler per message id
internal/services auth / user / redis business logic
internal/repositories mongo access
internal/mappers wire DTOs, internal/models documents, internal/constants RPC ids
scripts/smoke.py end-to-end client + smoke test
The first version committed here had never actually been run outside a laptop. Auditing it turned up four real problems, all fixed:
- The container was unreachable.
conf/zinx.jsonpinnedHost: 127.0.0.1, so the server bound to loopback inside the container: the healthcheck passed while every external connection was reset by the port forwarder. Now0.0.0.0. - A panic that could take the process down. In the interceptor, a failed
GetPropertywas reported and the connection closed, but execution continued intoproperty.(string)on a nil value, and the reader goroutine was not the only caller - a nil interface type assertion panics. It now returns early through a singlerejectpath. docker compose upcould not work from a clean clone. The compose file required a gitignored.env; the stack now starts with built-in development defaults and documents them in.env.example.- 47 MB of vendored third-party code (1828 files) was committed; dependencies now come from
the module cache, and
go.sumpins them.
- Facebook login (
1004) is a stub: the route is registered, the handler is empty. - Session keys in Redis have no TTL yet; a session is cleared on re-authentication and at boot.
- The chat room handler keeps rooms in memory (per process), which is what a single-node server needs and is the first thing to replace when you run more than one pod.
MIT - see LICENSE. Third-party dependencies keep their own licenses (go.mod).
go test -race ./...50 tests in 11 files, and they are where the two real bugs in this repository came from:
internal/utils(60%): JWT signing and verification, expiry, a token signed with the wrong key, and the error helper that turns failures into the codes the client sees.internal/services(74.8%): the chat room driven over real websocket connections - a client joining, leaving, leaving twice, a broadcast reaching every client in the room, a broadcast to an empty room, and a client whose socket died being dropped instead of being written to forever.internal/handlers(52.9%): the chat router's room map. The router is registered as an empty literal, so the first player to open the chat used to panic the process with assignment to entry in nil map; and because each connection is handled on its own goroutine, two players joining at once were a concurrent map write, which Go turns into an unrecoverable crash. Both are fixed (lazily created, mutex guarded map) and the -race test fails on the old code.
Coverage is low on purpose and honestly: most of this server is repositories talking to MongoDB and
Redis, and configuration read through viper, which need those services to mean anything. What can
be tested without them is tested, and the whole server is additionally exercised end to end by
scripts/smoke.py in CI, against the real MongoDB and Redis in docker-compose.yml.
%% Source for docs/diagrams/frame-to-answer.html
%% A frame of bytes becomes an answered request, and the gate it passes on the way.
flowchart LR
C["game client<br/>Unity / Cocos"] -->|"TCP :8999"| F["8-byte TLV frame<br/>msgId u32 BE<br/>length u32 BE<br/>payload"]
F --> RT["router<br/>dispatch by msgId"]
RT --> SG{"session gate<br/>per message, not per phase"}
SG -->|"auth messages<br/>1000-1006"| H["handler"]
SG -->|"1100 needs a session"| H
SG -.->|"no session"| ERR["9999 RpcError<br/>+ close"]
H --> S["services"] --> REPO["repositories"]
REPO --> R[("Redis<br/>live connId per account")]
REPO --> DB[("account documents")]
classDef gate fill:#eef5ef,stroke:#1a6b3c,stroke-width:2px;
classDef bad stroke:#b3261e;
class SG gate;
class ERR bad;
The architecture section above shows the boxes. This is the same path at the level of one frame: eight bytes
of header — msgId and length, both big endian — and a payload a reader can skip without parsing, because
the length is in the header and not inferred from a delimiter.
The point the picture is drawn to make is where the session is checked: per message, not once per
connection. The protocol table's auth column is a property of each message, and an auth message is by
definition one that arrives without a session, so a gate at connection time would have to special-case them
anyway.
The other rule is drawn underneath: one live session per account. The connection id of the live session is kept in Redis, so a second login is a takeover — the older connection is told, rather than left guessing why its messages stopped working.
docs/diagrams/frame-to-answer.mmd is the Mermaid source; make diagram exports a PNG if a browser is
present.
charts/zinx-server/ deploys the server with what a long-lived TCP connection needs: a rolling update that
never takes a replica out of the Service before its successor can accept (maxUnavailable: 0), a 60-second
grace period so a session is closed rather than cut, a PodDisruptionBudget, an HPA, no service-account token,
a read-only root filesystem, and a NetworkPolicy that allows the game port in and only DNS plus MongoDB and
Redis out.
The probe is a TCP connect on the game port, which is exactly what a client does — there is no HTTP path here to check, and claiming one would be inventing a fact.
make chart # helm lint --strict + helm template| package | statements |
|---|---|
internal/mappers |
100.0% |
internal/models |
100.0% |
internal/filters |
95.7% |
internal/repositories |
86.5% |
internal/services |
74.8% |
internal/configs |
21.7% |
internal/utils |
60.0% |
internal/handlers |
52.9% |
internal/routers, cmd/* |
0.0% |
| total | 57.1% |
The repository tests are integration tests - what they check is the queries and the bson tags behind
them, where a mistake does not fail but matches nothing - so they need a real MongoDB and skip
themselves without TEST_MONGO_URI. CI sets it from a mongo service container, and locally:
docker compose up -d mongodb
TEST_MONGO_URI='mongodb://root:devpassword@localhost:27017/?authSource=admin' make coverageThey own the database they are pointed at: they drop the users collection.
What the numbers do not say, and should: internal/routers and cmd are wiring, and the smoke test
in make up is what covers them. internal/configs is mostly untested on purpose: only InitConfig is
covered, because the connect functions report failure with log.Fatalf, so a
bad configuration exits the process rather than returning an error - that is a property of the
package, not a gap in the tests, and it is why a broken config.yaml shows up as a container that
will not start.