For a recent hackathon at work, I decided to create an API-based multiplayer game, something along the lines of SpaceTraders or Rubbled. The idea is the entire game is built in a service with a REST API for issuing commands. There is no UI or client provided; players must create their own user interface or automate the commands somehow. I’ve worked on games before, but I haven’t ever created an online multiplayer game, so I thought this would be a fun experiment. My desire was to create the game in Go so I could take advantage of the concurrency primitives it provides and spend some time getting familiar with the new slog package for logging. There wasn’t much time available to work on this project, due to other pressing work issues, but I was still able to get a simple service running with a few basic commands.

The theme for the game is mining rocks and minerals, so I named the game simply mining-post. Players can purchase resources and sell them for profit. The game world updates at a regular cadence and every update brings new prices, as well as a paycheck for the player. I intend for the game to eventually support passive mining operations, where the player purchases mines, pickaxes, and other equipment. The ultimate goal of the game is to make as much money as possible and player scores can be compared on a global leaderboard.

Here’s what a buy order looks like:

POST http://127.0.0.1:9090/market/buy
{
    "player": "tstark",
    "item": "Granite",
    "quantity": 10
}

200 OK
{
    "cost": 299.9,
    "message": "Successfully purchased 10 of item: Granite, total cost: 299.90"
}

And then here is what a call to view a player’s inventory looks like:

GET http://127.0.0.1:9090/player/tstark/inventory

200 OK
{
    "player": {
        "name": "tstark",
        "title": "Miner L1",
        "money": 9700.1,
        "salary": 1000,
        "inventory": [
            {
                "name": "Granite",
                "description": "Granite is a coarse-grained, igneous rock with a speckled appearance, widely used in construction for its hardness and durability.",
                "quantity": 10
            }
        ]
    }
}

Logging may not benefit the players directly, but it is crucial to any service for the purposes of debugging, auditing, and telemetry. Structured logs are JSON logs with key/value pairs for various important pieces of data. When structured logs are consumed by tools like OpenSearch or Kibana, those key/value pairs can be filtered on to more easily find related information. For example, a property in a structured log might be a unique Correlation ID shared by all logs for a single transaction. Searching for that Correlation ID will display only the logs for that transaction and eliminate the noise from other concurrent transactions.

There are many different logging packages out there that support structured logs, but up until recently there was no official structured logger. Enter slog, the official Go structured logger. slog is focused on speed, simplicity, and compatibility with the original log standard package. The default slog logger can be configured with a log level and output format like so:

logLevel := &slog.LevelVar{}
logLevel.Set(slog.LevelInfo)
opts := &slog.HandlerOptions{
	Level: logLevel,
}
logHandler := slog.NewJSONHandler(os.Stdout, opts)
slog.SetDefault(slog.New(logHandler))

Logs are submitted using key/value pairs, like so:

slog.Info("Handled request", "verb", req.Method, "method", req.URL.Path, "elapsed", time.Since(start))

This generates an info level log using the JSON handler that looks like the following:

{"time":"2023-12-10T08:30:11.753341468-08:00","level":"INFO","msg":"Handled request","verb":"POST","method":"/market/buy","elapsed":80300}

The same log message using the text handler looks like this:

time=2023-12-10T08:30:11.753-08:00 level=INFO msg="Handled request" verb=POST method=/market/buy elapsed=80.3µs

I thought it was pretty easy to use slog and I like how it formats the output. It has the ability to group attributes together in a single attribute for nesting in the JSON structure, which is important for a structured logger. Overall, it’s fairly barebones for a logger, but it’s easy to use, it simply works, and it’s part of the standard library.

The way the main game loop works is it updates the entire marketplace and pays player salaries all at once when an update is triggered. The updates are triggered on a set interval using a time.Ticker running in a goroutine:

const updateInterval = 10 * time.Second
m.ticker = time.NewTicker(updateInterval)
go func() {
	for range m.ticker.C {
		m.update()
	}
}()

In order to avoid conflicts on the data or a player getting prices that are old, the game manager acquires a write lock on the marketplace using a sync.RWMutex before performing any updates:

m.marketLock.Lock()
defer m.marketLock.Unlock()

// proceed to update market prices

Whenever a player attempts to retrieve market prices or place a buy order, the game manager acquires a read lock on the marketplace using the same mutex:

m.marketLock.RLock()
defer m.marketLock.RUnlock()

// proceed to buy, sell, and view market prices

What this means is any number of players can simultaneously access the marketplace and buy/sell, but all orders will be completed before the game manager can get a write lock to update the prices. The reverse is also true; the players must wait for the write lock to be unlocked before they can place orders or view market prices. This is due to the nature of the game state being stored completely in memory. A proper database could potentially be used to perform updates on the data in an atomic fashion without having to lock with a mutex.

This was a lot of fun to put together and I still want to expand it a bit more before I put it down for good. Right now there is no database backing the game, so all progress is lost if the service is stopped. Rate limiting might be needed to make sure overzealous players don’t accidentally DDOS the service. There is always more content that could be added in the form of additional resources and mining equipment. Maybe even some form of messaging between players and moderation tools for admins. Finally, the service is currently unsecured, so authentication/authorization would need to be added before it ever went live. Maybe I’ll release a playable version to the general public someday, but who knows. It’s fun to fiddle with!

AI cover artwork generated by NightCafe Studio