Stats & achievement tracking website for games. Follow other players & compare your stats. Showcase your achievement progress, games you've 100%'d, your rarest achievements, and more!
Player profiles, achievement showcases, and more.
openstats has a simple webapi for developers. Developers can track & update achievement progress, and log statistics such as playtime.
I'm tired of being locked into a proprietary game platform. It feels like Steam is the only platform that does achievements & stats somewhat right. It was able to achieve that through its monopolistic saturation as a gaming social network. I don't want Steam to be the only choice players have for simple things like achievement tracking and profile showcases.
WIP! Come back some time later™ and I'll have hopefully updated this to include more concrete self-hosting instructions.
Soon...
Openstats consists of a Go API in api/, and a TypeScript Svelte Website in web/.
git clone https://github.com/openstats-gh/openstats- Install docker & https://docs.docker.com/compose/install/
- Install go 1.25
- Install node.js 24 & npm 11
- Install
migratego install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3 - Install web dependencies
cd web npm i - Create
api/.env.localandweb/.env.local- See
api/.env.local.exampleandweb/.env.local.examplefor instructions
- See
Note
If you see something like command not found when trying to use migrate, chances are the gopath go/bin directory
isn't on your PATH! This is usually located in your home directory e.g. C:/Users/YourUserName/go/bin or
/home/username/go/bin. See go help install for more information.
Further reading:
go help install,go help build,go help run- A TUI for docker
- Docker CLI cheatsheet
- Docker Compose manual
- Fiber backend web framework
- Svelte & SvelteKit frontend framework
In api as current working directory.
Starting:
docker compose up -dStopping:
docker compose downThe local db is accessible at postgres://openstats:openstats@localhost:15432/openstats?sslmode=disable
The local pgadmin webserver is accessible at http://localhost:15433
Expects the postgres database to be alive. See above.
In api as current working directory.
go run .I recommend using an IDE with Go debugging integration such as VS Code or Jetbrains Goland, and setting up a run & debug configuration.
Expects the API to be alive. See above.
In web as current working directory.
npm run devExpects the API to be alive. See above.
In web as current working directory.
npm run openapi-typescriptIn api as current working directory.
Its fine to test changes to the database schema ad-hoc without creating a migration. However, if you intend to commit your changes, you must create a migration:
migrate create -ext sql -dir db/migrations a-summary-of-your-changesAfter writing your DDL for the new migration, regenerate Go models based on your changes:
go generateSee Add a SQL Query for more information on how we generate models & queries.
In api as current working directory.
migrate -source file://db/migrations -database postgres://openstats:openstats@localhost:15432/openstats?sslmode=disable upWe use sqlc to generate structs and functions for each table and query. sqlc is configured in api/sqlc.yaml to look for migrations at api/db/migrations, and for queries at api/db/sql.
Each sql file may hold many queries, and each query is separated by a comment like this:
-- name: FindUser :one
select * from users where id = $1 limit 1;
-- name: FindUserBySlug :one
select * from users where id = $1 limit 1;To generate the Go code after making changes or after adding a new query, run this with api as your working directory:
go generateSee the sqlc docs for more information on query annotations, parameterization, etc.
Each table gets its own struct, and each query gets its own function. If the function is parameterized with multiple parameters, then it'll get a params struct.
Given this query:
-- name: AddOrUpdateAchievement :one
insert into achievement (game_id, slug, name, description, progress_requirement)
values ($1, $2, $3, $4, $5)
on conflict(game_id, slug)
do update set name=excluded.name,
description=excluded.description,
progress_requirement=excluded.progress_requirement
returning case when achievement.created_at == achievement.updated_at then true else false end as is_new;Usage might look like this:
isNew, createErr := Queries.AddOrUpdateAchievement(ctx.Context(), query.AddOrUpdateAchievementParams{
GameID: game.ID,
Slug: achievementSlug,
Name: request.Name,
Description: request.Description,
ProgressRequirement: request.ProgressRequirement,
})
if createErr != nil {
log.Error(createErr)
return ctx.SendStatus(fiber.StatusInternalServerError)
}
if isNew {
newLocation, routeErr := ctx.GetRouteURL("readAchievement", fiber.Map{"devSlug": devSlug, "gameSlug": gameSlug, "achievementSlug": achievementSlug})
if routeErr == nil {
ctx.Location(newLocation)
}
return ctx.SendStatus(fiber.StatusCreated)
}If a query returns an entire table, e.g.:
-- name: FindUser :one
select * from users where id = $1 limit 1;Then a fully type-qualified usage would look like this:
import (
"github.com/dresswithpockets/openstats/app/db/query"
)
// ...
var user query.User, err error = Queries.FindUser(c.Context(), userId)