Inspired by the [Spacetraders API](https://spacetraders.io/), I wanted to write a game as an HTTP backend that can be played with a REST API. I also had a friend turn me on to [Autonauts](https://store.steampowered.com/app/979120/Autonauts/) a few months ago. After having it eat up a few of my weekends Factorio style, I wondered if the general idea of automating bots to build large systems would translate to a REST interface like that of Spacetraders.
So I came up with a general idea: have robots that are controlled via a REST API to move around, find scrap metal mines, and create more bots with that metal.
After setting up a small OpenAPI spec the first task I set out to work on was giving bots movement. Each bot can be accessed via a POST that has the bot's unique identifier and coordinates. After each POST request, the bots start moving to the coordinates in the request body at a constant speed. Implementing this ended up being a more interesting problem than I expected.
Typically, games are written with a loop that runs 30 (or more) times in a second. Each iteration (or tick) of the loop updates the state of the game. So let's say I had a bot at (0,0) and I wanted it to walk to (5,5). I would send a POST with `{x:5, y:5}`. The server would create a velocity vector based on these coordinates, and update the bot's position based on it every game tick. These position updates are so fast they create the illusion of smooth motion. The server could then intercept GET requests to return this position state.
There's a pretty obvious issue with this idea. In a traditional game loop a single user needs at least 30 function calls a second for it to run. Scaling to even ten users means the server needs at least 300 function calls a second. High end gaming systems tend to peak at being able to run 144(ish) calls a second, and I am expecting to run on a Raspberry Pi.
So I reimagined my game loop to operate more like a traditional CRUD app, where my "game loop" was whatever loop an HTTP server used to handle requests and keep itself up and running. Instead of updating the state in my loop at a constant interval, I had to find a way that I could update only when a user made a request to my server. I started with a simple but incomplete idea:
- Receive a request with (x,y) coordinates the user wants their bot to head to.
- Get the angle of the (x,y) coordinates relative to the bot's current position. This will be the direction of the bot's velocity vector. The magnitude of the bot's velocity can be any arbitrary constant.
Since displacement is a function of velocity with respect to time, we could use this velocity vector to determine the bot's position using the difference between the time the server received its request and the current time. I used this idea to write a function that can return a bot's new position based on its starting position, wanted destination, time of movement, current time, and velocity.
```go
func GetBotLocation2(
initialCoordinates api.Coordinates,
destinationCoordinates api.Coordinates,
movementStartTime time.Time,
currentTime time.Time,
botVelocity float64,
) (api.Coordinates) {
movementVector := api.Coordinates{
// Subtract from the initial coordinates to make sure the velocity
// vector starts at zero
X: destinationCoordinates.X - initialCoordinates.X,
Y: destinationCoordinates.Y - initialCoordinates.Y,
}
timeDelta := currentTime.Sub(movementStartTime).Seconds()
currentMovementMagnitude := timeDelta * botVelocity
// Atan2 gets the angle of a vector in terms of x, y coordinates
currentMovementDirection := math.Atan2(movementVector.Y, movementVector.X)
currentLocation := api.Coordinates{
X: (currentMovementMagnitude * math.Cos(currentMovementDirection)) +
initialCoordinates.X,
Y: (currentMovementMagnitude * math.Sin(currentMovementDirection)) +
initialCoordinates.Y,
}
return currentLocation
}
```
There's a problem here! What happens if a bot has already reached its destination? The server will continue to calculate its position as though it is still moving!
To fix this, I got the magnitude of the bot's destination vector and used it to compute the time the bot takes to reach its destination using the bot's velocity. If the elapsed time is greater than how long it takes to get to the destination, I just return the destination vector.
```go
func GetBotLocation2(
initialCoordinates api.Coordinates,
destinationCoordinates api.Coordinates,
movementStartTime time.Time,
currentTime time.Time,
botVelocity float64,
) (api.Coordinates) {
movementVector := api.Coordinates{
// Subtract from the initial coordinates to make sure the velocity
// vector starts at zero
X: destinationCoordinates.X - initialCoordinates.X,
Y: destinationCoordinates.Y - initialCoordinates.Y,
}
// TODO: Remove sqrt
movementVectorLen := math.Sqrt(math.Pow(movementVector.X, 2) +
math.Pow(movementVector.Y, 2))
timeToReachDestination := movementVectorLen / botVelocity
timeDelta := currentTime.Sub(movementStartTime).Seconds()
if timeDelta > timeToReachDestination {
return destinationCoordinates
}
currentMovementMagnitude := timeDelta * botVelocity
// Atan2 gets the angle of a vector in terms of x, y coordinates
currentMovementDirection := math.Atan2(movementVector.Y, movementVector.X)
currentLocation := api.Coordinates{
X: (currentMovementMagnitude * math.Cos(currentMovementDirection)) +
initialCoordinates.X,
Y: (currentMovementMagnitude * math.Sin(currentMovementDirection)) +
initialCoordinates.Y,
}
return currentLocation
}
```
I found one last edge case. There may be an erroneous situation where an API caller passes in a start time that comes after the end/current time. In this case I return an error.
```go
type GetBotLocationError struct {
message string
}
func (e *GetBotLocationError) Error() string {
return e.message
}
func GetBotLocation2(
initialCoordinates api.Coordinates,
destinationCoordinates api.Coordinates,
movementStartTime time.Time,
currentTime time.Time,
botVelocity float64,
) (api.Coordinates) {
if currentTime.Before(movementStartTime) {
return api.Coordinates{X: 0, Y: 0}, &GetBotLocationError{
message: "Current time cannot be before movement start time"}
}
movementVector := api.Coordinates{
// Subtract from the initial coordinates to make sure the velocity
// vector starts at zero
X: destinationCoordinates.X - initialCoordinates.X,
Y: destinationCoordinates.Y - initialCoordinates.Y,
}
// TODO: Remove sqrt
movementVectorLen := math.Sqrt(math.Pow(movementVector.X, 2) +
math.Pow(movementVector.Y, 2))
timeToReachDestination := movementVectorLen / botVelocity
timeDelta := currentTime.Sub(movementStartTime).Seconds()
if timeDelta > timeToReachDestination {
return destinationCoordinates, nil
}
currentMovementMagnitude := timeDelta * botVelocity
// Atan2 gets the angle of a vector in terms of x, y coordinates
currentMovementDirection := math.Atan2(movementVector.Y, movementVector.X)
currentLocation := api.Coordinates{
X: (currentMovementMagnitude * math.Cos(currentMovementDirection)) +
initialCoordinates.X,
Y: (currentMovementMagnitude * math.Sin(currentMovementDirection)) +
initialCoordinates.Y,
}
return currentLocation, nil
}
```
^70fd1e
In its final form, this function can determine the movement of a bot between two sets of coordinates and one time delta.
Before I could completely integrate this into my HTTP server code I had to handle another issue: a user can send requests to move the bot at any time, even if hasn't reached its destination! To address this, I implemented a ledger inspired by blockchains. I'll cover it more in part two of this series.
<font size=1>Published 2024-09-20</font>
<font size=2>Follow me on Twitter! @RyanMichaelTech</font>