Go · Gin · Postgres · Redis

Tournament API

A backend service for running player tournaments with a live leaderboard. Players are stored in Postgres, ranked in a Redis sorted set, and the whole thing runs as three containers on two isolated Docker networks.

Go 1.23 Gin GORM PostgreSQL 13 Redis 6 Docker Compose Swagger

What it does

A tournament is a pot of prize money that players buy into. Joining costs 50 coins. Every player carries a level and a balance, and those two numbers decide their rank while the tournament runs. When the tenth player joins, the tournament closes and the pot is paid out down the standings.

The interesting part is where the data lives. Postgres holds the durable records, players, tournaments and archived results, through GORM. Redis holds the thing that changes constantly, the live ranking, as a sorted set. Reading the current standings never touches Postgres, and a finished tournament gets written back to Postgres and dropped from Redis.

See it running

The service needs Go, Postgres and Redis, none of which run on a static page. What runs below is the same rule set reimplemented in the browser: the score formula, the buy-in, the closing threshold and the prize split are read straight from service/tournament.go. Players join on a timer, the ranking re-sorts as they do, and the pot pays out when the tournament closes.

If you have the real service running, switch to Live API and point it at your instance to drive the same panel from actual endpoints.

Summer Cup planned
0 of 10 players pot 1,000
Event log

Rank is decided by score, computed as level × 100 + money, exactly as calculateScore does in the Go service. Ties keep the earlier joiner ahead, which mirrors how a Redis sorted set holds insertion order for equal scores.

How a request moves through it

Every endpoint follows the same path down the layers. The router only parses and responds, the service holds the rules, validation guards anything heading for a write, and CRUD is the only layer that talks to a database. Nothing skips a step, which is what makes the rules testable without a running Postgres.

router gin handlers service the rules crud queries only internal/db connections validation model + gorm Postgres Redis calls writes uses checks first maps structs A handler that skipped the service layer would bypass every rule below it, so none of them do.
The four layers, and the two the service leans on sideways.

What happens when a player joins

Joining is the one call that touches both databases in a single request. The buy-in and the tournament record are Postgres work, the ranking update is Redis work, and the closing check sits in between.

client service Postgres Redis POST /tournaments/join load tournament, reject if finished check balance, then money -= 50 append player, close at 10 ZADD leaderboard score userID 200 joined The two databases are written in the same handler without a shared transaction, which is the weak point noted under Known gaps.
One join request, four writes, two databases.

Tournament lifecycle

planned ongoing finished first player joins tenth player joins what the code does today: planned straight to finished Nothing in the Go code assigns the ongoing state, so the middle box is intent rather than behaviour. See Known gaps. The simulation above shows the lifecycle as intended, with ongoing set on the first join.
The three states, and the transition that is missing from the code.

How it is deployed

Compose brings up three containers across two bridge networks with fixed addressing. The app sits on both, so it can serve clients on one network and reach the databases on the other. Postgres and Redis are never exposed to the app network.

Architecture diagram showing the app container on the 10.0.1.0/24 app network and 10.0.2.0/24 db network, with Postgres and Redis on the db network, and the internal Go package layout.
The full picture, from CI through Compose down to the package layout.

The rules

Every number below is taken from service/tournament.go and model/users.go, and the simulation runs on the same values.

RuleValueWhere it lives
Player scorelevel × 100 + moneycalculateScore
Cost to join a tournament50 coins, refused below thatJoinTournament
Players before a tournament closes10JoinTournament
Cost to level up100 + level × 50LevelUpUser
First placehalf the potcalculatePrize
Second placea quarter of the pot
Third placean eighth of the pot
Fourth place and belowa sixteenth of the pot each

The split is not capped at the pot. Ten players in a 1,000 coin tournament are paid 500, 250, 125, then 62 each for the remaining seven, which comes to 1,309. Whether that is a bug or a house subsidy is a design decision that has not been made yet.

API reference

Seventeen endpoints across users, tournaments and leaderboards. The running service serves this same list as Swagger UI at /swagger/index.html.

Players

POST/usersCreate a player from a name, balance and level.
GET/usersList every player.
GET/users/{id}Fetch one player.
PUT/users/{id}Update a player.
DELETE/users/{id}Remove a player.
POST/users/{id}/levelupSpend coins to gain a level and rescore.

Tournaments

POST/tournamentsCreate a tournament and its empty leaderboard.
GET/tournamentsList all tournaments with their players.
GET/tournaments/ongoingList tournaments in the ongoing state.
GET/tournaments/{id}Fetch one tournament.
PUT/tournaments/{id}Update a tournament.
DELETE/tournaments/{id}Remove a tournament.
POST/tournaments/joinBuy a player into a tournament.
POST/tournaments/{id}/endClose a tournament.

Leaderboards

GET/leaderboardCurrent standings, paged with start and stop.
GET/leaderboard/activeSame, filtered to active entries.
GET/leaderboard/tournament/{id}Standings for one tournament.
GET/leaderboard/tournament/{id}/activeActive entries for one tournament.
GET/leaderboard/tournament/{id}/finishedArchived result for a closed tournament.
GET/leaderboard/user/{id}Every board a player appears on.
GET/leaderboard/user/{id}/activeOnly the boards still running.

Operations

GET/healthPings both databases and reports which one is down.
POST/clear-databaseTruncates all three tables. Local use only.

Run it locally

Compose builds the binary, waits for Postgres to pass its health check, and starts the API on port 8080.

git clone https://github.com/yasinnerten/tournament.git
cd tournament

# .env is not committed. Create it first:
cat > .env <<'EOF'
POSTGRES_DSN=host=postgres user=postgres password=postgres dbname=tournament port=5432 sslmode=disable
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=tournament
REDIS_HOST=redis
REDIS_PORT=6379
EOF

docker compose --profile default up --build

Then create a few players and run a tournament:

curl -s localhost:8080/health

curl -X POST localhost:8080/users -H 'Content-Type: application/json' \
  -d '{"name":"Alice","money":100,"level":1}'
curl -X POST localhost:8080/users -H 'Content-Type: application/json' \
  -d '{"name":"Kassandra","money":500,"level":4}'

curl -X POST localhost:8080/tournaments -H 'Content-Type: application/json' \
  -d '{"name":"Summer Cup","prize":1000}'

curl -X POST localhost:8080/tournaments/join -H 'Content-Type: application/json' \
  -d '{"tournament_id":1,"user_id":1}'

curl -s localhost:8080/leaderboard

Swagger UI is at localhost:8080/swagger/index.html. Tests run with go test ./... or against containers with docker compose --profile test up --build.

Regenerating the Swagger docs

The docs/ package is generated and committed, and main.go imports it for its side effects. After changing any annotation, run swag init -g cmd/app/main.go -o docs or the build will use a stale spec.

Known gaps

These are real and currently unfixed. They are listed here rather than left for someone to trip over, and the simulation above quietly does the right thing in each case so the intended behaviour is still visible.

ProblemEffect
Nothing ever assigns model.Ongoing Tournaments go straight from planned to finished. GET /tournaments/ongoing always returns empty, and FinalizeTournament rejects every tournament it is handed because it requires the ongoing state.
GetTournamentByKey filters on a key column The Tournament struct has no such field, so GORM never creates the column and the query errors. This is the first call inside FinalizeTournament, so prize payout cannot run at all.
Redis writes to one global leaderboard key All tournaments share a single sorted set, so standings from different tournaments are mixed together. RemoveLeaderboardFromRedis deletes leaderboard:{id}, a key nothing ever writes, so the real set is never cleared.
SetLeaderboard ignores its key argument Entries are created without a TournamentID, so they cannot be filtered back to the tournament they belong to.
Money and Level are tagged validate:"required" The validator treats zero as missing, so a player who spends down to nothing can no longer be updated. gte=0 is what was meant.
EndTournament needs ten players or an already finished tournament A tournament cannot be closed early, though the comment above it says manual closing is the point.
Postgres and Redis are written without a shared transaction A join that succeeds in Postgres and then fails at Redis leaves the player charged and unranked. There is no compensating write.
The prize split can exceed the pot A full ten player tournament pays out more than it collected. See the note under The rules.

The buy-in threshold is worth a second look too. to-do.txt specifies a leaderboard once three players have joined, while the code closes the tournament at ten.

Contact