-
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.
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:
- Make a game (and a demo)
- Get a lot of people to play the demo during Steam Next Fest
- Get 6000+ wishlists
- Appear in Popular Upcoming
- ???
- 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.
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.
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.
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 fmtto 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 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
-iand 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.08For 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.08luluka sample/transition.kage -i image2.png -i image.png -v values.yaml
-
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.
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.
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:
- Clone the Godot source code:
git clone [email protected]:godotengine/godot.git - Generate an AES-256 key (32 bits in hex format):
openssl rand -hex 32 > godot.gdkey - Put this key in your environment variables:
export SCRIPT_AES256_ENCRYPTION_KEY=$(cat godot.gdkey) - Compile the new export templates (for wasm in this example):
scons platform=web target=template_release && scons platform=web target=template_debug - Set the new templates as custom templates in your export:
- 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”:
- Generate an IV (you should use a new one every export):
openssl rand -hex 16 - 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.
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.
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 functionFileAccessEncrypted::open_and_parseand the decryption logic in the functionFileAccessEncrypted::_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 thePackedSourcePCK::try_open_packfunction andFileAccessPackconstructor. 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_keyvariable itself is set at compile time by the scriptcore/core_builders.py. - Clone the Godot source code:
-
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.
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
IsKeyPressedorIsMouseButtonPressedare 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/Rewindfunctions, 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.
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_PASSWORDIt will write data to the
mysql_datafolder and use theSUPER_SECRET_PASSWORDpassword. You can then connect to this database by doing:docker compose exec mysql mysql -uroot -pSUPER_SECRET_PASSWORDFrom the database, you just have to create a
giteauser on thegiteadatabase: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_healthyIt will write files and other unstructured data to
gitea-dataand read its configuration file fromgitea-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.
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:3306and 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: - giteaThis 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
giteacontainer to receive connections from thewebsecureentry 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_healthyLastly, 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.inifile.[server] SSH_DOMAIN = COOL.DOMAIN.COM ROOT_URL = https://COOL.DOMAIN.COM/ DOMAIN = COOL.DOMAIN.COMAfter 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.
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.
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.sockFor 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
mysqldumpand 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