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.
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.
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.
Tournament lifecycle
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.
The rules
Every number below is taken from service/tournament.go and model/users.go, and the simulation runs on the same values.
| Rule | Value | Where it lives |
|---|---|---|
| Player score | level × 100 + money | calculateScore |
| Cost to join a tournament | 50 coins, refused below that | JoinTournament |
| Players before a tournament closes | 10 | JoinTournament |
| Cost to level up | 100 + level × 50 | LevelUpUser |
| First place | half the pot | calculatePrize |
| Second place | a quarter of the pot | |
| Third place | an eighth of the pot | |
| Fourth place and below | a 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 | /users | Create a player from a name, balance and level. |
| GET | /users | List every player. |
| GET | /users/{id} | Fetch one player. |
| PUT | /users/{id} | Update a player. |
| DELETE | /users/{id} | Remove a player. |
| POST | /users/{id}/levelup | Spend coins to gain a level and rescore. |
Tournaments
| POST | /tournaments | Create a tournament and its empty leaderboard. |
| GET | /tournaments | List all tournaments with their players. |
| GET | /tournaments/ongoing | List 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/join | Buy a player into a tournament. |
| POST | /tournaments/{id}/end | Close a tournament. |
Leaderboards
| GET | /leaderboard | Current standings, paged with start and stop. |
| GET | /leaderboard/active | Same, filtered to active entries. |
| GET | /leaderboard/tournament/{id} | Standings for one tournament. |
| GET | /leaderboard/tournament/{id}/active | Active entries for one tournament. |
| GET | /leaderboard/tournament/{id}/finished | Archived result for a closed tournament. |
| GET | /leaderboard/user/{id} | Every board a player appears on. |
| GET | /leaderboard/user/{id}/active | Only the boards still running. |
Operations
| GET | /health | Pings both databases and reports which one is down. |
| POST | /clear-database | Truncates 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.
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.
| Problem | Effect |
|---|---|
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
- GitHub: @yasinnerten
- LinkedIn: linkedin.com/in/yasinnerten
- Website: yasinnerten.com