Tortuga
note: this project is a work in progress and in early stages
The goal of tortuga is to be able to easily make tiny games. Tortuga is similar to turtle. However, several components have been stripped out (e.g. the lua interpretter, the web editor, etc...).
I'd like to focus on more core components of the game engine before building out the flashy stuff.
The structure of tortuga mimics that of a physical game console.
Cart
A cart (aka a game cartridge) is a representation of game code and memory.
A cart should implement the tortuga.Cart
interface.
type Cart interface {
Update()
Render()
}
Note: that some methods can only be called in the
Render
function e.g. most calls that deal with drawing things.
Example
The most simple implementation of this would look like the following:
you can run this example locally with the following command
go run github.com/dfirebaugh/tortuga/examples/simple
note: click the canvas to control the rect with WASD
package main
import (
"github.com/dfirebaugh/tortuga/pkg/input"
"github.com/dfirebaugh/tortuga/pkg/math/geom"
"github.com/dfirebaugh/tortuga"
)
type cart struct {
input input.PlayerInput
}
var (
game tortuga.Console
rect geom.Rect
speed = 4.0
)
func (c cart) Update() {
if c.input.IsDownPressed() {
rect[1] += speed
}
if c.input.IsUpPressed() {
rect[1] -= speed
}
if c.input.IsLeftPressed() {
rect[0] -= speed
}
if c.input.IsRightPressed() {
rect[0] += speed
}
}
func (c cart) Render() {
game.Clear()
// render a rectangle on the given display as a certain color
// draw calls need to happen in the render loop
rect.Draw(game.GetDisplay(), game.Color(2))
}
func main() {
// create a rectangle when the app starts (so we don't create on every render loop)
rect = geom.MakeRect(20, 20, 20, 20)
// instantiate the game console
game = tortuga.New()
// run the cart (note: this is a blocking operation)
game.Run(cart{
input: input.Keyboard{},
})
}
Geometry
The geom
library provides several geometry primatives that can be drawn to the screen.
With the geom
library, we can build primitives such as:
- circles
- rectangles
- triangles
- lines
Most primitives implement the Render
method that accept two arguments.
i.e. the display to draw to and the color that the primitive should be drawn as.
e.g.
rect := geom.MakeRect(20, 20, 20, 20)
// note the render calls can only be called inside a render loop
rect.Render(display, 2)
WAD
wad
files (which stands for where's all the data represents a mapping of files that contain data used to build a scene.
wad
files are written in a yaml format.
Each top level key represents a different behavior for how the asset will be marshalled into the game. File extensions don't actually matter.
example
palettes:
main: examples/platformer/assets/main.palette
tiles:
'#': examples/platformer/assets/brick.tile
'$': examples/platformer/assets/block.tile
sprites:
player: examples/platformer/assets/player.spr
heart: examples/platformer/assets/heart.spr
backgrounds:
platforms: examples/platformer/assets/background.map
Palette
Tortuga provides a default palette. However, each cart can define it's own palette.
A custom palette can be defined in a .palette
file. .palette
files should be in csv format.
Each record contains 3 numbers(r, g, b) 0-255.
In tortuga, colors are referenced by their index in the palette.
Example Palette file
0,0,0,
127,36,84,
28,43,83,
0,135,81,
171,82,54,
96,88,79,
195,195,198,
255,241,233,
237,27,81,
250,162,27,
247,236,47,
93,187,77,
81,166,220,
131,118,156,
241,118,166,
252,204,171,
Tile
A tile is represented by hexadecimal digits. Each digit refers to an index on the palette.
Tile Size
Tiles are 8x8 by default. you can configure tiles to be of a different size. However, tiles can only be square.
// set tiles to be 16x16
game.SetTileSize(16)
Tile Memory
Tile memory exists as a way to store tiles in a layer that doesn't have to rerender very often.
To add a tile to tile memory:
package main
import (
"github.com/dfirebaugh/tortuga"
"github.com/dfirebaugh/tortuga/pkg/sprite"
)
type cart struct{}
func (c cart) Update() {}
func (c cart) Render() {}
var game tortuga.Console
func main() {
game = tortuga.New()
// pushes a tile to tile memory
game.SetTile(2, 5, sprite.Parse("bbbbbbbbb4444444444455444444544445444444454444444444444444444444"))
game.Run(cart{})
}
Creating a tile
You could dynamnically create a tile in code.
e.g.
package main
import "github.com/dfirebaugh/tortuga"
type cart struct{}
func (c cart) Update() {}
func (c cart) Render() {}
var game tortuga.Console
func blockFactory(v uint8) []uint8 {
var b []uint8
for i := 0; i < 8*8; i++ {
b = append(b, v)
}
return b
}
func main() {
game = tortuga.New()
game.SetTile(2, 5, blockFactory(7))
game.Run(cart{})
}
Example
example of a tile file:
bbbbbbbbb4444444444455444444544445444444454444444444444444444444
Map
In the wad file, you can define a representation between a character and a tile.
This provides an easy way to push a lot of tiles into tile memory.
In the wad file, we have this representation configured:
tiles:
'#': examples/platformer/assets/brick.tile
'$': examples/platformer/assets/block.tile
backgrounds:
platforms: examples/platformer/assets/background.map
We can also use this file to build a collision map.
e.g.
// build a list of rectangles that somethign could collide against
func getCollidables() []geom.Rect {
c := []geom.Rect{}
for i, row := range strings.Split(assets.Assets.GetBackgrounds().Get("platforms"), "\n") {
for j, char := range row {
if char != '#' && char != '$' {
continue
}
c = append(c, geom.MakeRect(
float64(j*game.GetTileSize()),
float64(i*game.GetTileSize()),
float64(game.GetTileSize()),
float64(game.GetTileSize())))
}
}
return c
}
Example
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$......................................$
$......................................$
$......................................$
$....................###...............$
$......................................$
$................###...................$
$......................................$
$............##############............$
$......................................$
$......................................$
$......################################$
$......................................$
$......................................$
$###...................................$
$......................................$
$.......#########......................$
$..................@...................$
$......................................$
$....................#######...........$
$......................................$
$......................................$
$.............................#####....$
$............######....................$
$......................................$
$....####............#######...........$
$......................................$
$......................................$
$......................................$
$######################################$
Sprite
Sprite files are similar to tile files. The main difference is that you can build out a sprite file to represent named sprite animations.
Example
idle:
- 1111110011111111f6ff6f00ffffff000ccc00000111000001010000000000001111110011111111f6ff6f00ffffff000ccc0000011100000101000000000000
- 3333330033333333f6ff6f00ffffff000ccc00000333000003030000000000003333330033333333f6ff6f00ffffff000ccc0000033300000303000000000000
yaml format
Textures
It's more efficient to render something to a texture and then move that texture around than it is to render directly to the frame buffer on every render call.
Textures as a display target
Textures
also implement display.Displayer
which means that most of tortuga's drawing libraries can draw to a texture.
Rendering Textures
Textures can be pushed into the render pipeline to be queued for rendering.
Note that textures are rendered in the order that you push them into the render pipeline.
e.g.
package main
import (
"image/color"
"github.com/dfirebaugh/tortuga"
"github.com/dfirebaugh/tortuga/pkg/math/geom"
"github.com/dfirebaugh/tortuga/pkg/texture"
)
type cart struct {
}
var (
game tortuga.Console
)
func (c cart) Update() {}
func (c cart) Render() {
game.FillDisplay(2)
}
var (
circle = geom.MakeCircle(8, 8, 4)
t = texture.New(int(circle.Diameter()*2)+1, int(circle.Diameter()*2)+1)
)
func main() {
game = tortuga.New()
game.SetTransparentColor(color.Black)
t.X = float64(game.GetScreenWidth() / 2)
t.Y = float64(game.GetScreenHeight() / 2)
t.Alpha = 0xFF
circle.Filled(t, game.Color(3))
game.AddToRenderPipeline(t)
game.Run(cart{})
}
Simple Example
package main
type cart struct {
}
var (
game tortuga.Console
)
func (c cart) Update() {}
func (c cart) Render() {}
var (
heartPixels = sprite.Parse("0880088088888888888887888888888888888888088888800088880000088000")
heartTexture = texture.New(8, 8)
)
func main() {
game = tortuga.New()
heartTexture.X = float64(game.GetScreenWidth() / 2)
heartTexture.Y = float64(game.GetScreenHeight() / 2)
heartTexture.SetPix(heartPixels)
game.AddToRenderPipeline(heartTexture)
game.Run(cart{})
}
Sprite Editor
You can run the sprite editor with the following command:
go run github.com/dfirebaugh/tortuga/cmd/spritely@latest
At the moment, the encoded string of what's on the canvas will be output to the console. You can also save the entire animation to a file by pressing ctrl+s.
To open an existing sprite/animation, run the following command:
go run github.com/dfirebaugh/tortuga/cmd/spritely@latest -sprite ./examples/platformer/assets/heart.spr
Map Viewer
There is a simple map viewer that can be used to preview maps.
You will have to provide a wad file and a key for which tiles should be rendered.
e.g.
go run github.com/dfirebaugh/tortuga/cmd/mapviewer@latest -wad ./cmd/mapviewer/examples/game.wad -key tiles
The wad file will need relative paths to any assets required. e.g.
tiles:
'#': ./cmd/mapviewer/examples/brick.tile
'$': ./cmd/mapviewer/examples/block.tile
backgrounds:
tiles: ./cmd/mapviewer/examples/background.map
Tile Viewer
The tile viewer provides a very simple way to preview what an encoded tile looks like.
go run github.com/dfirebaugh/tortuga/cmd/tileviewer@latest -tile 0880088088888888888887888888888888888888088888800088880000088000
Text
you can run this locally with the following command
go run github.com/dfirebaugh/tortuga/examples/font
package main
import "github.com/dfirebaugh/tortuga"
var console tortuga.Console
type cart struct{}
func (c cart) Update() {}
func (c cart) Render() {
console.PrintAt("hello, world!", 10, 100, 4)
}
func main() {
console = tortuga.New()
console.SetTitle("font example")
console.SetScaleFactor(3)
console.Run(cart{})
}
Tiles
you can run this locally with the following command
go run github.com/dfirebaugh/tortuga/examples/tiles
package main
import "github.com/dfirebaugh/tortuga"
type cart struct{}
func (c cart) Update() {
}
func (c cart) Render() {}
var game tortuga.Console
func blockFactory(v uint8) []uint8 {
var b []uint8
for i := 0; i < 8*8; i++ {
b = append(b, v)
}
return b
}
func main() {
game = tortuga.New()
for i := 0; i < game.GetScreenWidth()/game.GetTileSize(); i++ {
game.SetTile(i, 0, blockFactory(uint8(i+1)))
game.SetTile(0, i, blockFactory(uint8(i+1)))
game.SetTile(i, i, blockFactory(uint8(i+1)))
}
game.SetTile(2, 5, blockFactory(7))
game.SetFPSEnabled(true)
game.SetScaleFactor(5)
game.Run(cart{})
}
Textures
you can run this locally with the following command
go run github.com/dfirebaugh/tortuga/examples/texture
package main
import (
"image/color"
"github.com/dfirebaugh/tortuga"
"github.com/dfirebaugh/tortuga/pkg/sprite"
"github.com/dfirebaugh/tortuga/pkg/texture"
)
type cart struct {
}
var (
game tortuga.Console
)
func (c cart) Update() {
}
var s = sprite.Parse("0880088088888888888887888888888888888888088888800088880000088000")
var heartTexture = texture.New(8, 8)
func (c cart) Render() {
// setting the background to a different color
game.FillDisplay(2)
}
func main() {
game = tortuga.New()
game.SetFPSEnabled(true)
game.SetRenderPipelineDebug(true)
game.SetTransparentColor(color.Black)
game.SetScaleFactor(3)
heartTexture.X = float64(game.GetScreenWidth() / 2)
heartTexture.Y = float64(game.GetScreenHeight() / 2)
heartTexture.SetPix(s)
game.AddToRenderPipeline(heartTexture)
game.SetTile(10, 20, s)
game.SetTile(10, 10, s)
game.RenderPalette()
game.Run(cart{})
}
Axis Aligned Collisions
you can run this locally with the following command
go run github.com/dfirebaugh/tortuga/examples/aabb
note: click the canvas to control the rect with WASD
package main
import (
"github.com/dfirebaugh/tortuga/pkg/input"
"github.com/dfirebaugh/tortuga/pkg/math/geom"
"github.com/dfirebaugh/tortuga"
)
type cart struct {
input input.PlayerInput
}
var (
game = tortuga.New()
rect = geom.MakeRect(float64(game.GetScreenWidth()/2-10), float64(game.GetScreenHeight()/2-10), 20, 20)
rect1 = geom.MakeRect(float64(game.GetScreenWidth()/2-10), float64(game.GetScreenHeight()/2-10), 20, 20)
)
const (
speed = 4
)
func (c cart) Update() {
if c.input.IsDownPressed() {
rect1[1] += speed
}
if c.input.IsUpPressed() {
rect1[1] -= speed
}
if c.input.IsLeftPressed() {
rect1[0] -= speed
}
if c.input.IsRightPressed() {
rect1[0] += speed
}
}
func (c cart) Render() {
d := game.GetDisplay()
game.Clear()
rect.Draw(d, game.Color(3))
geom.MakePoint(rect1.GetCenter()).Draw(d, uint8(12))
if rect.IsAxisAlignedCollision(rect1) {
rect1.Draw(d, game.Color(4))
return
}
rect1.Draw(d, game.Color(2))
}
func main() {
game.Run(cart{
input: input.Keyboard{},
})
}
Swept AABB
you can run this locally with the following command
go run github.com/dfirebaugh/tortuga/examples/sweptaabb
you can run this locally with the following command
go run github.com/dfirebaugh/tortuga/examples/sweptaabb2
Circle Collision
controls: right click generates balls, left click removes them all
Static Resolution
Static resolution ensures that the circles do not overlap.
go run github.com/dfirebaugh/tortuga/examples/staticball
Dynamic Resolution
Dynamic resolution takes into account the velocity and mass of the circle and will deflect the circle into the opposite direction of the collision.
go run github.com/dfirebaugh/tortuga/examples/dynamicball
paddleball
package main
import (
"github.com/dfirebaugh/tortuga"
"github.com/dfirebaugh/tortuga/pkg/component"
"github.com/dfirebaugh/tortuga/pkg/input"
"github.com/dfirebaugh/tortuga/pkg/math/geom"
)
type entity interface {
Update()
Render()
}
type cart struct {
entities []entity
Game tortuga.Console
}
func (c cart) Update() {
for _, e := range c.entities {
e.Update()
}
}
func (c cart) Render() {
c.Game.Clear()
for _, e := range c.entities {
e.Render()
}
}
const (
paddleSpeed = 3
ballSpeed = 1.1
)
var (
game = tortuga.New()
ball = Ball{
Coordinate: component.Coordinate{
X: float64(game.GetScreenWidth()) / 2,
Y: float64(game.GetScreenHeight()) / 2,
},
Velocity: component.Velocity{
VX: ballSpeed,
VY: ballSpeed,
},
Game: game,
}
player = paddle{
Game: game,
Coordinate: component.Coordinate{X: 10, Y: 10},
Height: 30,
Width: 5,
Mover: playerMover{
Game: game,
},
BallPosition: &ball.Coordinate,
Speed: paddleSpeed,
}
enemy = paddle{
Game: game,
Coordinate: component.Coordinate{X: float64(game.GetScreenWidth()) - 20, Y: 10},
Height: 30,
Width: 5,
Mover: enemyMover{
Game: game,
},
BallPosition: &ball.Coordinate,
Speed: paddleSpeed,
}
)
func main() {
game.Run(&cart{
Game: game,
entities: []entity{
&ball,
&player,
&enemy,
},
})
}
type Ball struct {
component.Coordinate
component.Velocity
Game tortuga.Console
}
func (b *Ball) Reset() {
b.Coordinate.X = float64(game.GetScreenWidth()) / 2
b.Coordinate.Y = float64(game.GetScreenHeight()) / 2
b.VX = ballSpeed
b.VY = ballSpeed
}
func (b *Ball) Update() {
if b.Coordinate.Y < 0 {
b.VY *= -ballSpeed
}
if b.Coordinate.Y > float64(b.Game.GetScreenHeight()) {
b.VY *= -ballSpeed
}
if b.Coordinate.X > float64(b.Game.GetScreenWidth()) || b.Coordinate.X < 0 {
b.Reset()
}
collisionBox := geom.MakeRect(b.X-4, b.Y-4, 4, 4)
if collisionBox.IsAxisAlignedCollision(geom.MakeRect(enemy.X, enemy.Y, enemy.Width, enemy.Height)) ||
collisionBox.IsAxisAlignedCollision(geom.MakeRect(player.X, player.Y, player.Width, player.Height)) {
b.VX *= -ballSpeed
}
b.Coordinate.Y += b.VY
b.Coordinate.X += b.VX
}
func (b Ball) Render() {
geom.MakeCircle(b.X, b.Y, 1).Filled(b.Game.GetDisplay(), b.Game.Color(4))
}
type mover interface {
Move(paddlePosition component.Coordinate, ballPosition component.Coordinate, speed float64) (x float64, y float64)
}
type playerMover struct {
input input.Keyboard
Game tortuga.Console
}
type enemyMover struct {
Game tortuga.Console
}
func (p playerMover) Move(paddlePosition component.Coordinate, ballPosition component.Coordinate, speed float64) (x float64, y float64) {
if p.input.IsDownPressed() {
return 0, speed
}
if p.input.IsUpPressed() {
return 0, -speed
}
if p.input.IsLeftPressed() {
return -speed, 0
}
if p.input.IsRightPressed() {
return speed, 0
}
return 0, 0
}
func (e enemyMover) Move(paddlePosition component.Coordinate, ballPosition component.Coordinate, speed float64) (x float64, y float64) {
if ballPosition.Y-10 > paddlePosition.Y {
return 0, speed
}
return 0, -speed
}
type paddle struct {
component.Coordinate
Mover mover
Game tortuga.Console
Speed float64
BallPosition *component.Coordinate
Height float64
Width float64
}
func (p *paddle) Update() {
x, y := p.Mover.Move(p.Coordinate, *p.BallPosition, p.Speed)
p.Coordinate.X += x
p.Coordinate.Y += y
}
func (p paddle) Render() {
geom.MakeRect(p.X, p.Y, p.Width, p.Height).Filled(p.Game.GetDisplay(), p.Game.Color(4))
}
gameoflife
you can run this locally with the following command
go run github.com/dfirebaugh/tortuga/examples/gameoflife
package main
import (
"fmt"
"math/rand"
"github.com/dfirebaugh/tortuga"
"github.com/dfirebaugh/tortuga/pkg/component"
"github.com/dfirebaugh/tortuga/pkg/math/geom"
)
type cart struct {
}
var (
game tortuga.Console
cells []*cell
generation = 0
tick = 0
)
func (c cart) Update() {
if tick%5 == 0 {
generation += 1
for _, cell := range cells {
cell.iteration()
}
for _, cell := range cells {
cell.isAlive = cell.nextGen
}
}
tick++
}
func (c cart) Render() {
game.Clear()
geom.MakeRect(0, 0, float64(game.GetScreenWidth()), float64(game.GetScreenHeight())).
Filled(game.GetDisplay(), game.Color(0))
game.PrintAt(fmt.Sprintf("gen: %d", generation), 10, 25, 2)
for _, cell := range cells {
cell.Render()
}
}
type cell struct {
geom.Rect
isAlive bool
component.Coordinate
nextGen bool
}
func (c *cell) Update() {}
func (c cell) Render() {
if !c.isAlive {
return
}
c.Filled(game.GetDisplay(), game.Color(5))
}
func (c *cell) iteration() {
if !c.isAlive {
c.nextGen = c.shouldReproduce()
return
}
c.nextGen = !c.shouldDie()
}
func (c *cell) shouldDie() bool {
if c.isUnderpopulated() || c.isOverpopulated() {
return true
}
return false
}
func (c cell) isOverpopulated() bool {
return c.getAliveNeighborCount() > 3
}
func (c cell) isUnderpopulated() bool {
return c.getAliveNeighborCount() < 2
}
func (c cell) shouldReproduce() bool {
return c.getAliveNeighborCount() == 3
}
func (c cell) getAliveNeighborCount() int {
x := c.X
y := c.Y
potential := []component.Coordinate{
{Y: y, X: x - 1}, //left
{Y: y, X: x + 1}, //right
{Y: y - 1, X: x}, //up
{Y: y + 1, X: x}, //down
{Y: y - 1, X: x - 1}, //left top diagnal
{Y: y + 1, X: x + 1}, //right bottom diagnal
{Y: y - 1, X: x + 1}, // left bottom diagnal
{Y: y + 1, X: x - 1}, // right top diagnal
}
neighbors := []bool{}
for _, n := range potential {
if n.X < 0 || n.Y < 0 || n.X > float64(game.GetScreenWidth()) || n.Y > float64(game.GetScreenHeight()) {
continue
}
if game.GetScreenWidth()*int(n.Y)+int(n.X) >= len(cells) {
continue
}
if cells[game.GetScreenWidth()*int(n.Y)+int(n.X)].isAlive {
neighbors = append(neighbors, true)
}
}
return len(neighbors)
}
func main() {
game = tortuga.New()
game.SetScaleFactor(3)
game.SetFPSEnabled(true)
for y := 0; y < game.GetScreenHeight(); y++ {
for x := 0; x < game.GetScreenWidth(); x++ {
c := &cell{}
c.X = float64(x)
c.Y = float64(y)
c.Rect = geom.MakeRect(c.X, c.Y, 1, 1)
c.isAlive = rand.Intn(100) < 25
cells = append(cells, c)
}
}
game.Run(cart{})
}
sfx
note: audio is a work in progress.
tone
note: audio is a work in progress.
package main
import (
"time"
"github.com/dfirebaugh/tortuga"
"github.com/dfirebaugh/tortuga/pkg/input"
)
type cart struct {
input input.PlayerInput
game tortuga.Console
notes []string
currentNote int
}
func (c *cart) Update() {
if c.input.IsUpJustPressed() {
if c.currentNote == 0 {
c.currentNote = len(c.notes)
}
c.currentNote = (c.currentNote - 1) % len(c.notes)
}
if c.input.IsDownJustPressed() {
c.currentNote = (c.currentNote + 1) % len(c.notes)
}
if c.input.IsRightJustPressed() {
c.game.PlayNote(c.game.Notes()[c.notes[c.currentNote]], time.Millisecond*250)
}
if c.input.IsLeftJustPressed() {
go func() {
c.game.PlayNote(c.game.Frequency("c4"), time.Second)
}()
go func() {
c.game.PlayNote(c.game.Frequency("e4"), time.Second)
}()
go func() {
c.game.PlayNote(c.game.Frequency("c4"), time.Second)
}()
}
}
func (c *cart) Render() {
c.game.Clear()
c.game.PrintAt(c.notes[c.currentNote], c.game.GetScreenWidth()/2, c.game.GetScreenHeight()/2, 6)
c.game.PrintAt("press right arrow to play tone", 10, 180, 5)
c.game.PrintAt("press up/down arrow to select a different note", 10, 195, 5)
}
func main() {
game := tortuga.New()
notes := []string{}
for n := range game.Notes() {
notes = append(notes, n)
}
game.Run(&cart{
input: input.Keyboard{},
game: game,
notes: notes,
})
}
sequence
note: audio is a work in progress.
package main
import (
"time"
"github.com/dfirebaugh/tortuga"
"github.com/dfirebaugh/tortuga/pkg/input"
)
type cart struct {
input input.PlayerInput
game tortuga.Console
}
var (
happy = []float32{
264,
264,
297,
264,
352,
330,
264,
264,
297,
264,
396,
352,
264,
264,
264,
440,
352,
352,
330,
297,
466,
466,
440,
352,
396,
352,
}
ode = []string{
"e4",
"e4",
"f4",
"g4",
"g4",
"f4",
"e4",
"d4",
"c4",
"c4",
"d4",
"e4",
"e4",
"d4",
"d4",
"e4",
"e4",
"f4",
"g4",
"g4",
"f4",
"e4",
"d4",
"c4",
"c4",
"d4",
"e4",
"d4",
"c4",
"c4",
}
)
func (c cart) Update() {
if c.input.IsDownJustPressed() {
go func() {
c.game.PlayNotes(ode, time.Millisecond*550)
}()
}
if c.input.IsUpJustPressed() {
go func() {
c.game.PlaySequence(happy, time.Millisecond*250)
}()
}
}
func (c cart) Render() {
c.game.PrintAt("press up arrow to play sequence 1", 10, 170, 5)
c.game.PrintAt("press down arrow to play sequence 2", 10, 180, 5)
}
func main() {
game := tortuga.New()
game.Run(cart{
input: input.Keyboard{},
game: game,
})
}
spectrum
you can run this locally with the following command
go run github.com/dfirebaugh/tortuga/examples/spectrum