• Posted on

    Steam nerfed popular upcoming. So what?

    In the latest Steam client update, Valve unveiled major changes to its store’s home page. One of the main difference is that the “Popular Upcoming” section, which shows upcoming games now shows a lot fewer titles. This seems to worry indie developers who relied on this section as a major driver of visibility before launch. I personally think this is will not change much to how indie games are marketed and might even be a positive for many niche developers.

    Steam's new popular upcoming page

    Popular upcoming is dead

    Game development is more accessible than ever, and anyone can post their games on Steam and Epic. All those new developers needing to market their games has created a cottage industry of game marketing influencers. The strategy they teach is basically always a variant of:

    1. Make a game (and a demo)
    2. Get a lot of people to play the demo during Steam Next Fest
    3. Get 6000+ wishlists
    4. Appear in Popular Upcoming
    5. ???
    6. Profit

    Since everyone is running around with approximately the same strategy, it became a very crowded trade. With demos getting more and more polished, Next Fest began to have diminishing returns for most developers. In the same way Popular Upcoming was very impactful when maybe 1 or 2 games appeared in it every day, but when it started showing a dozen game a day, it stopped being a guarantee of success.

    And let’s be honest, players have probably been paying less and less attention to this section of the store for a while. Unless they were really interested in finding out what every shovelware publisher has been working on.

    Steam's new personal calendar upcoming section

    Long live the Personal Calendar

    In the same update, Valve introduced a new feature: the Personal Calendar. It’s a whole page which aims at making players discover games that are match their taste.

    The recommendation algorithm seems to work somewhat decently. I do not think it is entirely based on tags, since it seems to recommend me a lot of “Open World Survival Craft” games, which is not something I usually play. It may be using wishlist behavior of similar players to make recommendations.

    One of the major differences is that it shows a calendar for the next eight weeks, so games can have a much longer visibility window. If a player of your target audience didn’t browse Steam on the day before your launch, it would miss you on popular upcoming, while now they have six weeks to do that.

    It also shows recently released games, in the past 7 days and past month. Contrary to the homepage “Popular New Releases” tab (ex “New & Trending”), which requires a lot of active players, this section seems accessible even to unknown games. Steam is currently recommending me Imago Season with (at the moment of writing) 0 review and only 2 concurrent players.

    Steam's new personal calendar past 7 days section

    A new era for niche games?

    Before this change, it was extremely difficult for developers to market niche games. I know it from first-hand experience, since I have no doubt Dice ‘n Goblins would have been easier to market if it was not the weirdest combination of ideas possible.

    Because of this, a lot of new indie game developers were advised to stick to a popular genre. You love real-time strategy? Too bad, you’ll have to make an action roguelike instead.

    This has caused a loss of creativity in the indie game world. With many developers working on genres that do not interest them, using recycled ideas and aesthetics.

    I do believe that this new version of Steam might swing the pendulum in the other way. Now that players of niche games can get recommendation customized to their taste, making games for a non-mainstream audience can become viable again.

  • Posted on

    Preview Ebitengine shaders with Luluka

    Lately, I’ve been experimenting with building games in Ebitengine. It’s a 2D engine that lets you create games using the Go programming language. To be able to iterate faster on visual effects, I have created a tool that lets me preview shaders: Luluka.

    Luluka showing a basic shader

    Kage Shaders

    For those who feel like they missed an episode, shaders are small programs running on the GPU which can be used to modify the pixels of an image. In games, they are used everywhere to control how a game looks. They can be used create special effects like blurring an image or making your screen look like a CRT from the 90s.

    Ebitengine has its own shader language called Kage. It is very convenient because it has a syntax extremely close to Go. Close enough that you can even run go fmt to format your Kage files. Under the hood, Ebitengine will automatically convert Kage shaders into a format understandable by the GPU.

    Quasilyte’s article about Ebitengine shaders is generally the most comprehensive introduction to the subject. You can also learn more about them in the Kage’s desk.

    Moria Luluka transforming into Cure Shadow Arcana in the anime Star Detective Precure
    Moria Luluka from Star Detective Precure! by Toei Animation

    Luluka

    One of the main difference between Ebitengine and other game engines like Godot, is that it doesn’t have a visual editor. This is generally fine, since I’ve spent most of my career avoiding What-You-See-Is-What-You-Get tools in favor of staying inside NeoVim.

    But for shaders, you can end up spending a lot of time tweaking a few variables until you get them to look right. That’s why I decided to build my own tool that would let me work on a shader in isolation, and quickly change the variables we give it.

    You can install Luluka using the following command:

    go install github.com/Tsukumogami-Software/[email protected]
    

    Run a shader by pointing it straight to the file, passing textures with -i and uniform values with -u:

    luluka sample/transition.kage -i image2.png -i image.png -u Steepness:80 -u Seed.0:15 -u Seed.1:100 -u Seed.2:5000 -u Seed.3:5000 -u Speed:0.08
    

    For more convenience, you can use a YAML file to pass your uniform values. This is especially practical when working with arrays or matrices, since commands can get very long:

    Steepness: 80
    Seed: [15.0, 100.0, 5000.0]
    Speed: 0.08
    
    luluka sample/transition.kage -i image2.png -i image.png -v values.yaml
    
    The luluka -h output
  • Posted on

    Protecting Godot games against reverse engineering

    Godot games are known to be easy to reverse engineer. Simple tools can extract assets and source code from the packaged files. If you are making commercial games you probably want to take some steps to avoid this.

    A key
    Photo by Jorien Loman

    Godot RE Tools

    The most popular Godot reverse engineering software is gdsdecomp aka Godot RE Tools. It’s very multi-platform, simple to use, and even comes with a GUI.

    In a few clicks, you can “Recover” a project from an executable or .pck file. Gdsdecomp is able to find back the file structure of the project, every asset you exported, and the source code with full variable and function names.

    Godot RE Tools decrypting some simple animation code

    This is actually really well-made software. It even comes with convenient utilities for people who want to patch translations (one of the many use cases of reverse engineering games).

    Encrypting .pck files

    First let’s preface this section with a warning. There’s no way to 100% guarantee that it’s going to be impossible to decompile a game, aside from never distributing the executable. The only thing we can do is make reverse engineering more difficult and time-consuming.

    Anyway, the recommended way of protecting Godot games against reverse engineering is to encrypt the files inside it. This is done using AES-256, and requires compiling custom export templates.

    You can find the details in the official docs, but the general idea is this:

    1. Clone the Godot source code:
       git clone [email protected]:godotengine/godot.git
      
    2. Generate an AES-256 key (32 bits in hex format):
       openssl rand -hex 32 > godot.gdkey
      
    3. Put this key in your environment variables:
       export SCRIPT_AES256_ENCRYPTION_KEY=$(cat godot.gdkey)
      
    4. Compile the new export templates (for wasm in this example):
       scons platform=web target=template_release && scons platform=web target=template_debug
      
    5. Set the new templates as custom templates in your export:
      Advanced options in Godot Project Export showing Custom Templates being set
    6. In your export encryption settings, check “Encrypt Exported PCK”, “Encrypt Index”, and do not forget to put the files and folders you want to encrypt in “Filters to include”:
      Encryption options in Godot Project Export showing Filters to include
    7. Generate an IV (you should use a new one every export):
       openssl rand -hex 16
      
    8. Last but not least, set your AES key and IV in the encryption settings page.

    If you did everything correctly, your exported .pck should not be readable by gdsdecomp without knowing the key. At the same time, players should still be able to play the game as usual without issues or needing to know what AES-256 means.

    Sadly, the key is stored in plain text in memory, and it is not very hard to find it there. According to an estimate I just made up, it would take a 12 years-old with an hex editor and a YouTube tutorial around 15 minutes to get the key. There’s even a tool that promises it can find it in only 50 ms.

    A sample encryption key visible in Ghidra

    Godot-Secure

    To solve this problem, we need to obfuscate the decryption process a little bit. For those that feel this sounds terribly complicated, there’s a script called Godot-Secure that was built to help you with that.

    It will modify Godot source code to significantly alter the decryption process. Instead of directly using the key we store in memory, it will use it and a secret token to derivate a second key and decipher the files with it. In addition, to that, it will change a few magic numbers and can switch the algorithm from AES-256 to Camellia-256.

    Once you have run the script, you will need to recompile both the export templates and the editor. This is because the editor is responsible for encrypting the files during export.

    Gdsdecomp failing to decompile a project

    After exporting a file with the secured Godot, the attackers can still easily obtain our key from the binary files. However, this key is useless by itself. They will also need to find the secret token, work through the key derivation method and re-implement the decryption algorithm. This can take a lot of time and requires actual programming knowledge.

    Improving the obfuscation

    Of course, if you want to play around with some C++, you can make this a bit more robust by adding custom logic of your own. There are two files that will be relevant to you.

    First one is core/io/file_access_encrypted.cpp. It contains the encryption logic in the function FileAccessEncrypted::open_and_parse and the decryption logic in the function FileAccessEncrypted::_close.

    CryptoCore::AESContext ctx;
    
    ctx.set_encode_key(key.ptrw(), 256); // Due to the nature of CFB, same key schedule is used for both encryption and decryption!
    ctx.decrypt_cfb(ds, iv.ptrw(), data.ptrw(), data.ptrw());
    

    The second is core/io/file_access_pack.cpp. This one contains how the key is loaded from memory in the PackedSourcePCK::try_open_pack function and FileAccessPack constructor. Be careful when modifying this part, as changes with how you load the key will need to be reflected in how you set the key from the editor.

    Vector<uint8_t> key;
    #ifdef TOOLS_ENABLED
    if (!p_decryption_key.is_empty()) {
    	ERR_FAIL_COND_MSG(p_decryption_key.size() != 32, "Decryption key must be 256-bit.");
    	constexpr uint8_t empty_key[32] = {};
    	if (memcmp(script_encryption_key, empty_key, sizeof(empty_key)) == 0) {
    		key = p_decryption_key;
    	}
    } else
    #endif
    {
    	key.resize(32);
    	memcpy(key.ptrw(), script_encryption_key, 32);
    }
    

    The script_encryption_key variable itself is set at compile time by the script core/core_builders.py.

  • Posted on

    Making games in Go with Ebitengine

    Like a lot of people, you may be using Go at work to develop backend applications or create infrastructure tools. But you may not be aware that you can actually make video games in Go using Ebitengine.

    A bowl of fried shrimps
    Photo by gomding

    Just enough to make games

    Ebitengine aims at being dead simple. It was explicitly designed to have the most minimalistic API possible and still let people make the games they want. Of course, it has some limitations, for example it doesn’t handle 3D (at least, not officially). But it is still functional enough that many commercial indie games have been made with it.

    Switching to Ebitengine from Unity or Godot is a bit like switching from Spring Framework to a micro-framework like Gin or Chi. At first, you’ll feel like you’re spending all your time re-inventing the wheel, but when you start getting used to it, you realize that the increased flexibility and reliability makes your life easier in the long run.

    Graphics

    The core of Ebitengine is its ability to display and manipulate images. The screen itself is considered an image, on which we are going to draw the rest of our game. Each time we display an image using DrawImage, we can give it options. Those allow us to change its position, scale, rotation, color or filtering.

    Source code
    package main
    
    import (
    	"bytes"
    	_ "embed"
    	"image/png"
    	"log"
    	"math/rand"
    
    	"github.com/hajimehoshi/ebiten/v2"
    )
    
    var game *Game
    
    //go:embed logo.png
    var logoFile []byte
    
    const (
    	maxSpeed = 3
    	minSpeed = 1
    	scale    = 2
    )
    
    func randomColor() ebiten.ColorScale {
    	scale := ebiten.ColorScale{}
    	scale.Scale(
    		rand.Float32(),
    		rand.Float32(),
    		rand.Float32(),
    		1,
    	)
    	return scale
    }
    
    func randomSpeed() float64 {
    	return minSpeed + rand.Float64()*(maxSpeed-minSpeed)
    }
    
    // Game implements ebitengine Game interface and represents our game loop
    type Game struct {
    	image     *ebiten.Image
    	geometry  ebiten.GeoM
    	direction [2]float64
    	color     ebiten.ColorScale
    	width     int
    	height    int
    }
    
    // Update is called every frame to update the current game state
    func (g *Game) Update() error {
    	g.geometry.Translate(
    		g.direction[0],
    		g.direction[1],
    	)
    
    	// bounce and change color if out of screen
    	x := g.geometry.Element(0, 2)
    	x2 := x + float64(g.image.Bounds().Dx())*scale
    	if x < 0 {
    		g.direction[0] = randomSpeed()
    		g.color = randomColor()
    	} else if x2 > float64(g.width) {
    		g.direction[0] = -1 * randomSpeed()
    		g.color = randomColor()
    	}
    
    	y := g.geometry.Element(1, 2)
    	y2 := y + float64(g.image.Bounds().Dy())*scale
    	if y < 0 {
    		g.direction[1] = randomSpeed()
    		g.color = randomColor()
    	} else if y2 > float64(g.height) {
    		g.direction[1] = -1 * randomSpeed()
    		g.color = randomColor()
    	}
    
    	return nil
    }
    
    // Draw is called every frame to display images on the screen
    func (g *Game) Draw(screen *ebiten.Image) {
    	screen.DrawImage(g.image, &ebiten.DrawImageOptions{
    		GeoM:       game.geometry,
    		ColorScale: g.color,
    	})
    }
    
    // Layout is called every frame to indicate the window/screen size
    func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    	g.width = outsideWidth
    	g.height = outsideHeight
    	return outsideWidth, outsideHeight
    }
    
    func init() {
    	reader := bytes.NewReader(logoFile)
    	png, err := png.Decode(reader)
    	if err != nil {
    		log.Panicf("Failed to decode image:\n%v", err)
    	}
    	image := ebiten.NewImageFromImage(png)
    
    	geom := ebiten.GeoM{}
    	geom.Scale(2, 2)
    
    	direction := [2]float64{
    		randomSpeed(),
    		randomSpeed(),
    	}
    	color := randomColor()
    
    	game = &Game{
    		image:     image,
    		geometry:  geom,
    		direction: direction,
    		color:     color,
    	}
    }
    
    func main() {
    	if err := ebiten.RunGame(game); err != nil {
    		log.Fatal(err)
    	}
    }
    


    Input

    Like any proper engine, Ebitengine helps you handle player input. Basic functions like IsKeyPressed or IsMouseButtonPressed are directly found inside the main ebiten module. More advanced functions are placed in the inpututil module. Those can be very useful if you need to work with controllers or touchscreens.

    Source code (main.go)
    package main
    
    import (
    	"bytes"
    	_ "embed"
    	"image/color"
    	"image/png"
    	"log"
    
    	"github.com/hajimehoshi/ebiten/v2"
    	"github.com/hajimehoshi/ebiten/v2/inpututil"
    )
    
    var game *Game
    
    const (
    	screenWidth  = 160
    	screenHeight = 240
    )
    
    // Game is our top level structure
    type Game struct {
    	egg    *Egg
    	nests  []*Nest
    	scroll float64
    
    	touchIDs []ebiten.TouchID
    }
    
    // Update handles the user input, movement and scrolling
    func (g *Game) Update() error {
    	if g.scroll > 0 {
    		g.HandleScrolling()
    		return nil // the rest of the game logic is blocked during scrolling
    	}
    
    	g.touchIDs = inpututil.AppendJustPressedTouchIDs(g.touchIDs[:0])
    	if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) ||
    		inpututil.IsKeyJustPressed(ebiten.KeySpace) ||
    		len(g.touchIDs) != 0 {
    		g.egg.Jump()
    	}
    
    	g.egg.UpdatePosition()
    	for _, nest := range g.nests {
    		nest.UpdatePosition()
    		if g.egg.IsFalling() {
    			if nest.CheckLanding(g.egg) {
    				g.nests = append(g.nests, NewMovingNest())
    				g.scroll = screenHeight / 3
    			}
    		}
    	}
    
    	return nil
    }
    
    // HandleScrolling smoothly moves the elements by a third of the screen and removes old nests
    func (g *Game) HandleScrolling() {
    	distance := float64(screenHeight) / 30
    	g.scroll -= distance
    	g.egg.Scroll(distance)
    
    	visibleNests := []*Nest{}
    	for _, nest := range g.nests {
    		nest.Scroll(distance)
    		if !nest.IsOutOfScreen() {
    			visibleNests = append(visibleNests, nest)
    		}
    	}
    	g.nests = visibleNests
    }
    
    // Draw displays the background, egg and nests
    func (g *Game) Draw(screen *ebiten.Image) {
    	screen.Fill(color.RGBA{
    		R: 77,
    		G: 186,
    		B: 233,
    		A: 255,
    	})
    	g.egg.Draw(screen)
    	for _, nest := range g.nests {
    		nest.Draw(screen)
    	}
    }
    
    // Layout returns a constant screen width and height
    func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    	return screenWidth, screenHeight
    }
    
    func loadImage(file []byte) *ebiten.Image {
    	reader := bytes.NewReader(file)
    	png, err := png.Decode(reader)
    	if err != nil {
    		log.Panicf("Failed to decode image:\n%v", err)
    	}
    	return ebiten.NewImageFromImage(png)
    }
    
    func init() {
    	eggImage = loadImage(eggFile)
    	nestImage = loadImage(nestFile)
    	game = &Game{
    		egg:      NewEgg(),
    		nests:    []*Nest{NewBottomNest(), NewTopNest()},
    		touchIDs: []ebiten.TouchID{},
    		scroll:   0,
    	}
    }
    
    func main() {
    	if err := ebiten.RunGame(game); err != nil {
    		log.Fatal(err)
    	}
    }
    
    Source code (egg.go)
    package main
    
    import (
    	_ "embed"
    
    	"github.com/hajimehoshi/ebiten/v2"
    )
    
    const (
    	eggWidth  = 16
    	eggHeight = 16
    )
    
    //go:embed egg.png
    var eggFile []byte
    var eggImage *ebiten.Image
    
    // Egg represents our player
    type Egg struct {
    	geom     ebiten.GeoM
    	velocity float64
    	gravity  float64
    }
    
    // NewEgg creates an egg at the beginning of the game
    func NewEgg() *Egg {
    	eggGeom := ebiten.GeoM{}
    	eggGeom.Translate(
    		screenWidth/2-eggWidth/2,
    		screenHeight*2/3,
    	)
    	return &Egg{
    		geom:     eggGeom,
    		velocity: 0,
    		gravity:  0,
    	}
    }
    
    // Draw draws the egg sprite on the screen
    func (e *Egg) Draw(screen *ebiten.Image) {
    	screen.DrawImage(eggImage, &ebiten.DrawImageOptions{
    		GeoM: e.geom,
    	})
    }
    
    // Jump triggers the start of a jump
    func (e *Egg) Jump() {
    	if e.velocity < 0 {
    		return // we're already jumping
    	}
    	e.velocity = -10
    	e.gravity = 0.5
    }
    
    // UpdatePosition moves the egg during jumps / falls
    func (e *Egg) UpdatePosition() {
    	e.velocity += e.gravity
    	e.geom.Translate(
    		0,
    		e.velocity,
    	)
    }
    
    // GetPosition returns the egg X/Y
    func (e *Egg) GetPosition() (float64, float64) {
    	eggX := e.geom.Element(0, 2)
    	eggY := e.geom.Element(1, 2)
    	return eggX, eggY
    }
    
    // Stop cancels any jump / fall
    func (e *Egg) Stop() {
    	e.gravity = 0
    	e.velocity = 0
    }
    
    // IsFalling returns true if the egg is currently falling
    func (e *Egg) IsFalling() bool {
    	return e.velocity > 0
    }
    
    // Scroll moves the egg during scrollig
    func (e *Egg) Scroll(distance float64) {
    	e.geom.Translate(0, distance)
    }
    
    Source code (nest.go)
    package main
    
    import (
    	_ "embed"
    	"math/rand"
    
    	"github.com/hajimehoshi/ebiten/v2"
    )
    
    const (
    	nestWidth  = 32
    	nestHeight = 16
    )
    
    //go:embed nest.png
    var nestFile []byte
    var nestImage *ebiten.Image
    
    // Nest represents the platforms
    type Nest struct {
    	geom     ebiten.GeoM
    	landed   bool
    	velocity float64
    }
    
    // NewBottomNest creates the starting point
    func NewBottomNest() *Nest {
    	nestGeom := ebiten.GeoM{}
    	nestGeom.Translate(
    		screenWidth/2-nestWidth/2,
    		screenHeight*2/3+eggHeight/2,
    	)
    	return &Nest{
    		geom:     nestGeom,
    		landed:   true,
    		velocity: 0,
    	}
    }
    
    // NewTopNest creates the second (fixed) nest
    func NewTopNest() *Nest {
    	nestGeom := ebiten.GeoM{}
    	nestGeom.Translate(
    		screenWidth/2-nestWidth/2,
    		screenHeight/3+eggHeight/2,
    	)
    	return &Nest{
    		geom:     nestGeom,
    		landed:   false,
    		velocity: 0,
    	}
    }
    
    // NewMovingNest creates a moving platform
    func NewMovingNest() *Nest {
    	nestGeom := ebiten.GeoM{}
    	nestGeom.Translate(
    		screenWidth/2-nestWidth/2,
    		0,
    	)
    	velocity := 0.5 + rand.Float64()
    	if rand.Intn(2) == 1 {
    		velocity *= -1
    	}
    	return &Nest{
    		geom:     nestGeom,
    		landed:   false,
    		velocity: velocity,
    	}
    }
    
    // Draw displays the nest on the screen
    func (n *Nest) Draw(screen *ebiten.Image) {
    	screen.DrawImage(nestImage, &ebiten.DrawImageOptions{
    		GeoM: n.geom,
    	})
    }
    
    // UpdatePosition moves the nest
    func (n *Nest) UpdatePosition() {
    	n.geom.Translate(n.velocity, 0)
    	x := n.geom.Element(0, 2)
    	if x < 0 || x+nestWidth > screenWidth {
    		n.velocity *= -1
    	}
    }
    
    // CheckLanding returns true if the egg has landed in a new nest
    func (n *Nest) CheckLanding(egg *Egg) bool {
    	eggX, eggY := egg.GetPosition()
    	nestX := n.geom.Element(0, 2)
    	nestY := n.geom.Element(1, 2)
    	if eggX >= nestX &&
    		eggX+eggWidth <= nestX+nestWidth &&
    		eggY+eggHeight >= nestY+5 &&
    		eggY+eggHeight <= nestY+15 {
    		// landing in a nest
    		egg.Stop()
    		if !n.landed {
    			// we landed in a new nest
    			n.velocity = 0
    			n.landed = true
    			return true
    		}
    		return false
    	}
    	return false
    }
    
    // Scroll moves the nest during scrolling
    func (n *Nest) Scroll(distance float64) {
    	n.geom.Translate(0, distance)
    }
    
    // IsOutOfScreen returns true if the nest is not displayed anymore
    func (n *Nest) IsOutOfScreen() bool {
    	y := n.geom.Element(1, 2)
    	return y > screenHeight
    }
    


    Audio

    Ebitengine’s audio module contains everything you need to play sound effects or music. On top of providing the usual Play/Pause/Rewind functions, it handles decoding of mp3, ogg, and wav audio files.

    Low-level management of audio stream is available through the oto library, which is also part of the Ebitengine project.

    Source code (sample.go)
    package main
    
    import (
    	"bytes"
    	"embed"
    	"image/png"
    	"io/fs"
    	"log"
    	"path/filepath"
    
    	"github.com/hajimehoshi/ebiten/v2"
    	"github.com/hajimehoshi/ebiten/v2/audio"
    	"github.com/hajimehoshi/ebiten/v2/audio/wav"
    )
    
    const (
    	sampleWidth  = 36
    	sampleHeight = 36
    )
    
    //go:embed images
    var images embed.FS
    
    //go:embed sounds
    var sounds embed.FS
    
    // Sample represents a sound sample and its icon
    type Sample struct {
    	image  *ebiten.Image
    	geom   ebiten.GeoM
    	player *audio.Player
    }
    
    // IsTargeted returns true if the mouse or touch position is on the sample
    func (s *Sample) IsTargeted(x, y int) bool {
    	sampleX := int(s.geom.Element(0, 2))
    	sampleY := int(s.geom.Element(1, 2))
    	return x >= sampleX &&
    		x < sampleX+sampleWidth &&
    		y >= sampleY &&
    		y < sampleY+sampleWidth
    }
    
    // Play rewinds and plays the sample
    func (s *Sample) Play() {
    	err := s.player.Rewind()
    	if err != nil {
    		log.Panicf("Failed to rewind player: %v", err)
    	}
    	s.player.Play()
    }
    
    // Draw displays the sample icon
    func (s *Sample) Draw(screen *ebiten.Image) {
    	screen.DrawImage(s.image, &ebiten.DrawImageOptions{
    		GeoM: s.geom,
    	})
    }
    
    func createPlayer(context *audio.Context, filename string) *audio.Player {
    	file, err := fs.ReadFile(
    		sounds,
    		filepath.Join("sounds", filename),
    	)
    	if err != nil {
    		log.Panicf("Failed to read embedded sounds fs: %v", err)
    	}
    	reader := bytes.NewReader(file)
    
    	stream, err := wav.DecodeWithSampleRate(sampleRate, reader)
    	if err != nil {
    		log.Panicf("Failed to decode sample: %v", err)
    	}
    
    	player, err := context.NewPlayer(stream)
    	if err != nil {
    		log.Panicf("Failed to create player: %v", err)
    	}
    	return player
    }
    
    func loadImage(filename string) *ebiten.Image {
    	file, err := fs.ReadFile(
    		images,
    		filepath.Join("images", filename),
    	)
    	if err != nil {
    		log.Panicf("Failed to read embedded images fs: %v", err)
    	}
    	reader := bytes.NewReader(file)
    
    	png, err := png.Decode(reader)
    	if err != nil {
    		log.Panicf("Failed to decode image:\n%v", err)
    	}
    	return ebiten.NewImageFromImage(png)
    }
    
    // Skull creates our skull sample (top left)
    func Skull(context *audio.Context) *Sample {
    	return &Sample{
    		image:  loadImage("skull.png"),
    		player: createPlayer(context, "skull.wav"),
    		geom:   ebiten.GeoM{},
    	}
    }
    
    // Alert creates our alert sample (top right)
    func Alert(context *audio.Context) *Sample {
    	geom := ebiten.GeoM{}
    	geom.Translate(36, 0)
    	return &Sample{
    		image:  loadImage("alert.png"),
    		player: createPlayer(context, "alert.wav"),
    		geom:   geom,
    	}
    }
    
    // Question creates our question sample (bottom left)
    func Question(context *audio.Context) *Sample {
    	geom := ebiten.GeoM{}
    	geom.Translate(0, 36)
    	return &Sample{
    		image:  loadImage("question.png"),
    		player: createPlayer(context, "question.wav"),
    		geom:   geom,
    	}
    }
    
    // Heart creates our heart sample (bottom right)
    func Heart(context *audio.Context) *Sample {
    	geom := ebiten.GeoM{}
    	geom.Translate(36, 36)
    	return &Sample{
    		image:  loadImage("heart.png"),
    		player: createPlayer(context, "heart.wav"),
    		geom:   geom,
    	}
    }
    
    Source code (main.go)
    package main
    
    import (
    	"log"
    
    	"github.com/hajimehoshi/ebiten/v2"
    	"github.com/hajimehoshi/ebiten/v2/audio"
    	"github.com/hajimehoshi/ebiten/v2/inpututil"
    )
    
    const (
    	screenWidth  = 72
    	screenHeight = 72
    	sampleRate   = 44100
    )
    
    var game *Game
    
    // Game contains a collection of samples
    type Game struct {
    	samples []*Sample
    }
    
    // Update plays a sample when it is clicked or touched
    func (g *Game) Update() error {
    	if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
    		for _, sample := range g.samples {
    			if sample.IsTargeted(ebiten.CursorPosition()) {
    				sample.Play()
    			}
    		}
    	}
    
    	touchIDs := inpututil.AppendJustPressedTouchIDs(nil)
    	for _, touchID := range touchIDs {
    		for _, sample := range g.samples {
    			if sample.IsTargeted(ebiten.TouchPosition(touchID)) {
    				sample.Play()
    			}
    		}
    	}
    	return nil
    }
    
    // Draw displays every sample's icon
    func (g *Game) Draw(screen *ebiten.Image) {
    	for _, sample := range g.samples {
    		sample.Draw(screen)
    	}
    }
    
    // Layout returns a fixed 72 * 72 layout
    func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    	return screenWidth, screenHeight
    }
    
    func init() {
    	context := audio.NewContext(sampleRate)
    	game = &Game{
    		samples: []*Sample{
    			Skull(context),
    			Alert(context),
    			Question(context),
    			Heart(context),
    		},
    	}
    }
    
    func main() {
    	if err := ebiten.RunGame(game); err != nil {
    		log.Fatal(err)
    	}
    }
    


    Shaders

    Go code usually runs on the CPU. Sometimes if your game needs faster processing for animations or effects, you may want to use shaders. Shaders are small pieces of software that will be executed on the GPU. This allows you to execute a function over every pixel of an image in parallel.

    Ebitengine comes with its own shader language called Kage. It is very similar to Go. So much, that syntax highlighting tools that work with Go should not have any problems working with Kage. If you want to learn more about this, I recommend checking out tinne26’s Kage’s desk

    Source code (main.go)
    package main
    
    import (
    	_ "embed"
    	"log"
    	"time"
    
    	"github.com/hajimehoshi/ebiten/v2"
    )
    
    //go:embed shader.kage
    var shaderFile []byte
    
    const (
    	screenWidth  = 512
    	screenHeight = 512
    )
    
    var game *Game
    
    // Game contains the compiled shader and keeps track of the time
    type Game struct {
    	shader    *ebiten.Shader
    	startTime time.Time
    }
    
    // Draw displays the shader on the entire screen
    func (g *Game) Draw(screen *ebiten.Image) {
    	screen.DrawRectShader(
    		screenWidth,
    		screenHeight,
    		g.shader,
    		&ebiten.DrawRectShaderOptions{
    			Uniforms: map[string]any{
    				"Center": []float32{
    					float32(screenWidth) / 2,
    					float32(screenHeight) / 2,
    				},
    				"Time": time.Now().Sub(g.startTime).Seconds(),
    			},
    		},
    	)
    }
    
    // Update does nothing here
    func (g *Game) Update() error {
    	return nil
    }
    
    // Layout returns a fixed width/height
    func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    	return screenWidth, screenHeight
    }
    
    func init() {
    	shader, err := ebiten.NewShader(shaderFile)
    	if err != nil {
    		log.Panicf("Failed to create shader: %f", err)
    	}
    	game = &Game{
    		shader:    shader,
    		startTime: time.Now(),
    	}
    }
    
    func main() {
    	if err := ebiten.RunGame(game); err != nil {
    		log.Fatal(err)
    	}
    }
    
    Source code (shader.kage)
    //kage:unit pixels
    package main
    
    // Center is the coordinates of the center of the screen
    var Center vec2
    
    // Time allows us to update the shader over time
    var Time float
    
    // Fragment is the main shader function, run over every pixel
    func Fragment(targetCoords vec4, sourceCoords vec2, color vec4) vec4 {
        //delta is the vector from the center to our pixel
    	delta := targetCoords.xy - Center
    	distance := length(delta)
    	angle := atan2(delta.y, delta.x)
    
        //band is going to generate alternate bands based on distance and angle
    	spiralValue := distance + Time * 50.0 - 10.0 * angle
    	band := mod(spiralValue, 63.0)
    
    	if band < 32.0 {
    		return vec4(1, 0, 0, 1) // red
    	} else {
    		return vec4(0, 0, 0, 1) // black
    	}
    }
    


    More than just PC games

    Go natively works well on Windows, Mac, and Linux. As you can see above, it can also run in your browser with WebAssembly. Thanks to some pretty clever tricks, you can actually compile go for basically any system that implements a libc. For example, you can compile Ebitengine games for the Nintendo Switch (as long as you have access to the required SDK and hardware).

    Since Ebitengine is pretty flexible, it can be used for other things than games. Guigui is a GUI Framework that makes use of it. It is currently in alpha, and is being very actively developed by the same people who made Ebitengine.

  • Posted on

    Self-hosting your code on Gitea

    Afraid of GitHub suddenly enshittifying their product? The best way to shield yourself from that is to start self-hosting your code repositories. I have been running a self-hosted instance of Gitea for around two months, and so far the process has been pretty close to painless.

    A cup of tea and a teapot
    Photo by Content Pixie

    Preparing a machine

    To install Gitea, all you need is a server with 2 GB RAM and 2 CPU cores. The official documentation even says a single GB of RAM is enough, which I can believe, but a bit of margin will not hurt.

    Once your server is started and configured as you like, you’ll need to prepare a database. Gitea supports both MySQL and PostgreSQL. Personally, I like MySQL a lot, so I decided to go with that.

    Using docker-compose, you can launch a MySQL container like this:

      mysql:
        image: mysql:8.4
        restart: always
        volumes:
          - ./mysql_data:/var/lib/mysql
        environment:
          MYSQL_ROOT_PASSWORD: "SUPER_SECRET_PASSWORD"
        healthcheck:
          test: mysqladmin ping -pSUPER_SECRET_PASSWORD
    

    It will write data to the mysql_data folder and use the SUPER_SECRET_PASSWORD password. You can then connect to this database by doing:

    docker compose exec mysql mysql -uroot -pSUPER_SECRET_PASSWORD
    

    From the database, you just have to create a gitea user on the gitea database:

    CREATE USER 'gitea'@'%' IDENTIFIED BY 'TOP_SECRET_PASSWORD';
    
    CREATE DATABASE gitea CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_bin';
    
    GRANT ALL PRIVILEGES ON gitea.* TO 'gitea';
    FLUSH PRIVILEGES;
    

    Installing Gitea

    Installing Gitea itself is very straightforward. It is a single process that you can launch through a single binary.

    I use docker-compose to make networking easier, since I launched MySQL that way:

      gitea:
        image: docker.gitea.com/gitea:1.24.6-rootless
        restart: always
        volumes:
          - ./gitea-data:/var/lib/gitea
          - ./gitea-config:/etc/gitea
          - /etc/timezone:/etc/timezone:ro
          - /etc/localtime:/etc/localtime:ro
        ports:
          - "80:3000"
          - "2222:2222"
        depends_on:
          mysql:
            condition: service_healthy
    

    It will write files and other unstructured data to gitea-data and read its configuration file from gitea-config. HTTP traffic will be received on the port 80 and forwarded to 3000, while SSH traffic (used by git itself) will use port 2222. We do not use port 22 to avoid conflicts with the server’s own SSH daemon.

    The gitea installation wizard

    As soon as the container is ready, you can head connect to Gitea with your web browser at your server’s IP/domain. It will open an installation wizard, and you just have to follow the instructions. Through this installation wizard, you should be able to connect to the previously configured MySQL server using the address mysql:3306 and the user you created earlier.

    Setting up TLS

    Just because we are self-hosting doesn’t mean we have to forgo basic security rules. Using Traefik is, in my opinion, the simplest way to set up TLS on your own server.

    First we will need to launch a Traefik container:

      traefik:
        image: traefik:v3.5
        restart: always
        command:
          - "--providers.docker=true"
          - "--entrypoints.websecure.address=:443"
          - "--entrypoints.web.address=:80"
          - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
          - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
          - "--entrypoints.web.http.redirections.entrypoint.permanent=true"
          - "--certificatesresolvers.myresolver.acme.tlschallenge=true"
          - "[email protected]"
          - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - ./letsencrypt:/letsencrypt
          - /var/run/docker.sock:/var/run/docker.sock
        depends_on:
          - gitea
    

    This container will create two entry points:

    • websecure, that will receive the TLS-traffic on port 443
    • web, that will receive the unsecured traffic on port 80 and simply redirect to websecure

    It will also use TLS challenges to prove you own the server to Let’s Encrypt and receive a valid certificate.

    You will need to add labels on the gitea container to receive connections from the websecure entry point on port 3000:

      gitea:
        image: docker.gitea.com/gitea:1.24.6-rootless
        restart: always
        volumes:
          - ./gitea-data:/var/lib/gitea
          - ./gitea-config:/etc/gitea
          - /etc/timezone:/etc/timezone:ro
          - /etc/localtime:/etc/localtime:ro
        ports:
          - "2222:2222"
        labels:
          - "traefik.http.routers.gitea.rule=Host(`COOL.DOMAIN.COM`)"
          - "traefik.http.routers.gitea.entrypoints=websecure"
          - "traefik.http.routers.gitea.tls.certresolver=myresolver"
          - "traefik.http.services.gitea.loadbalancer.server.port=3000"
        depends_on:
          mysql:
            condition: service_healthy
    

    Lastly, if you didn’t do it during installation, you should indicate the proper domain and URL in Gitea’s config. It is found in the gitea-config/app.ini file.

    [server]
    SSH_DOMAIN = COOL.DOMAIN.COM
    ROOT_URL = https://COOL.DOMAIN.COM/
    DOMAIN = COOL.DOMAIN.COM
    

    After restarting everything, crossing fingers and waiting a bit, you should receive a valid TLS certificate and be able to use Gitea safely.

    Importing repositories from GitHub

    You can automatically import repositories from GitHub into Gitea. You can find the import tool on the + button located in the top right corner of your screen.

    In addition to importing the code, Gitea can automatically retrieve existing labels, issues, pull requests, releases, and milestones using a Personal Access Token.

    The Gitea import menu

    Compared to alternatives like GitLab, the killer feature of Gitea is that you do not need to rewrite your GitHub Action workflows. So you can just leave your YAML files untouched in .github/workflows.

    You will need to start some Runner processes to run your Actions.

    First, you’ll have to generate a Runner registration token from your instance, organization or repository setting. As you may have guessed, a Runner with a registration token created from a repository will only be able to access this repository, while a runner created from the instance settings will be able to access every repository.

    The runners settings page

    When you have a token, you can launch a runner container like this:

      gitea-runner:
        image: docker.io/gitea/act_runner:0.2.13
        environment:
          CONFIG_FILE: /config.yaml
          GITEA_INSTANCE_URL: "https://COOL.DOMAIN.COM"
          GITEA_RUNNER_REGISTRATION_TOKEN: "TOKEN_YOU_JUST_GENERATED"
          GITEA_RUNNER_NAME: "actions-runner"
        volumes:
          - ./runner-config.yaml:/config.yaml
          - ./runner-data:/data
          - /var/run/docker.sock:/var/run/docker.sock
    

    For scalability and security reasons, this can, and probably should, be done from a second machine.

    Backing up Gitea

    While I’ve not encountered any issues operating Gitea so far, it is important to do regular backups to avoid losing work in case something goes wrong.

    Gitea provides a built-in backup utility called gitea dump. You can invoke it using Docker like that:

    docker exec -u git -it -w --tempdir ./temp $(docker ps -qf 'name=gitea-1$') bash -c '/usr/local/bin/gitea dump -c /etc/gitea/app.ini'
    

    Alternatively, you should be able to back up the MySQL database using mysqldump and make zip of Gitea local volumes.

    Backups have to be copied to a service you trust, and that is independent of where your server is hosted. Every major cloud provider offers reasonably cheap object storage that can be used for this.

subscribe via RSS