Full Width [alt+shift+f] Shortcuts [alt+shift+k]
Sign Up [alt+shift+s] Log In [alt+shift+l]
124
Since 2019, Apple has required all MacOS software to be signed and notarized. This is meant to prevent naive users from installing malware while running software from unknown sources. Since this process is convoluted, it stops many indie game developers from releasing their Godot games on Mac. To solve this, this article will attempt to document each and every step of the signing and notarization process. Photo by Natasya Chen Step 0: Get a Mac While there tools exists to codesign/notarize Mac executables from other platforms, I think having access to a MacOS machine will remove quite a few headaches. A Mac VM, or even a cloud machine, might do the job. I have not personally tested those alternatives, so if you do, please tell me if it works well. Step 1: Get an Apple ID and the Developer App You can create an Apple ID through Apple’s website. While the process should be straightforward, it seems like Apple has trust issues when it comes to email from protonmail.com or custom domains. Do not hesitate to contact support in case you encounter issues creating or logging into your Apple ID. They are quite responsive. Once you have a working Apple ID, use it to log into the App Store on your Mac and install the Apple Developer application. Step 2: Enroll in the Apple Developer Program Next, open the Apple Developer app, log in, and ask to “Enroll” in the developer program. This will require you to scan your ID, fill in data about you and your company, and most likely confirm those data with a support agent by phone. The process costs ~99$ and should take between 24 and 48 hours. Step 3: Setup Xcode Xcode will be used to codesign and notarize your app through Godot. You should install the app through the App Store like you did for the Apple Developer application. Once the app is installed, we need to accept the license. First, launch Xcode and close it. Then open a terminal and run the following commands: sudo xcode-select -s...
12th Sep 2024

Stay updated

Get a weekly newsletter with the top 5 articles worth reading every week.

More from Alice GG

GodotCon Amsterdam 2026

In April, the Godot foundation organized a conference close to their home base: GodotCon Amsterdam. It was a good occasion for us to showcase Paradox Tower and get some beta feedback from other developers. Most importantly it was two entire days of talks about game development in Godot. Here are some of the ones I recommend checking out if you are into it. 1000 rigged AnimationPlayers in 3D, impossible? Having a lot of animated 3D objects in Godot can seriously damage performance. This is something to look for if you’re developing for systems with limited resources, which may rely on the Compatibility renderer (like Android or Switch). Bertrand Bordage explains how to get around this limitation using Vertex Animation Textures. It basically consists of baking an animation into a shader. I had previously never heard of it and this talk really seems like a doable solution for Switch ports of 3D games. The Clone Wars: Defending Godot Games From Reupload Scams Godot games are very easy to reverse engineer. Many developers assume this is not an issue and will only be used by modders and curious players. Sadly, there’s a cottage industry of shady publishers who will take advantage of this to copy your game. I’ve previously talked a bit about technical countermeasures you can use to make this process harder. In this talk, Yasen Dinkov also explores the legal and marketing aspects of the problem. He provides ideas that can make taking down illegal store pages much easier. From model to gameplay in seconds: setting up your Blender Godot pipeline Doing a lot of manual steps can kill your productivity. A big source of this is manually importing 3D assets and setting them up as Godot scenes. Godot comes with a set of tools to speed that up: post-import scripts. Here, Marion Allard explains how to make the best use of it, with step-by-step examples using Blender objects: Make your own fractal! A more relaxed talk to finish, with a short and fun introduction to fractals by Marta Imke. It explores visual math and the different ways you can build fractal images out of simple functions. This is definitely something that can be useful if you want to create some trippy visual effects.

2nd Jul 2026 1 votes
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 games. This seems to worry indie developers who relied on this section as a major driver of visibility around 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 Now that game development is more accessible than ever, and anyone can post their games on Steam and Epic. Since all those new developers need to market their games, this 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+ Wishlist Appear in Popular Upcoming ??? Profit Since everyone is running around with approximately the same strategy, it became a very crowded trade. With everyone publishing a more and more polished demo, 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 if it shows a dozen game, it’s not a guarantee of success anymore. And let’s be honest, players have probably been paying less and less attention to this section of the store for a while. Basically every shovelware publisher knows how to game the algorithm and get their new version of Slop Simulator 2027 in their. 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 personalized to 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 can 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 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 very popular genre. You love real-time strategy? Too bad, you’ll have to make an action roguelike instead. This has led to 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.

12th Jun 2026 2 votes
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 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. 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

15th May 2026 1 votes
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. 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 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.

3rd Apr 2026 1 votes
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. 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. play1.onclick = () => { frame1.src = "/assets/2026-01-06-ebitengine/go_dvd.html"; frame1.style.display = 'block'; play1.style.display = 'none'; }; 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. play2.onclick = () => { frame2.src = "/assets/2026-01-06-ebitengine/egg.html"; frame2.style.display = 'block'; play2.style.display = 'none'; }; 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. play3.onclick = () => { frame3.src = "/assets/2026-01-06-ebitengine/sampler.html"; frame3.style.display = 'block'; play3.style.display = 'none'; }; 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 play4.onclick = () => { frame4.src = "/assets/2026-01-06-ebitengine/spiral.html"; frame4.style.display = 'block'; play4.style.display = 'none'; }; 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.

6th Jan 2026 1 votes

More in programming

Abusing ID3 chapters to turn videos into glanceable podcasts

I listen to a lot of podcasts, and I like how they fit around other tasks. I press play, lock my phone, and put it down. I’m free to wash the dishes, fold the laundry, or shop for groceries. Unfortunately, more and more information is only published as a video. Technical talks, conference sessions, video essays – they don’t work in an audio-only podcast app. I could convert these videos to MP3 files, but that breaks down the moment a video isn’t pure spoken word. If a speaker says, “Look at this slide” or holds up a diagram, an audio-only file leaves me stranded. I don’t want to give up the podcast player I like, nor stare at a screen for an hour – but I do want the information in these videos. To solve this, I’m abusing my podcast player’s chapter support. This gives me the best of both worlds: I can listen to a video as audio-first, and glance at my lock screen if I need a moment of visual context. The idea: Chapters every few seconds MP3 files can have ID3 metadata, and ID3 metadata can include chapters. A chapter covers a particular time range, and it can have an associated title, description, and cover art. My podcast app of choice is Overcast, which can’t play videos, but it does have robust chapter support. I can jump between chapters, navigate a table of contents, and see per-chapter cover art. To get videos into Overcast, I’m creating MP3 files with a new chapter every few seconds, and the per-chapter cover art is a corresponding frame from the video. As I play the file, I get a slow, stop-motion-like rendition of the original video. If my phone is locked, I can glance at my lock screen and see the current frame in the Now Playing screen. Overcast is developed by Marco Arment, and I got this idea from Forecast, his app for adding chapters to podcasts. In particular, I was struck by its ability to create chapters that don’t display in the chapter list – ideal if I don’t want a table of contents with hundreds of entries. As I was developing my script, I compared my output to the output from Forecast to ensure I was creating the chapters correctly. The code: FFmpeg and Mutagen There are three steps in this process: Convert a video file to an MP3 Extract images from the video at a fixed interval Insert the images as hidden chapters in the MP3 file Let’s go through each in turn. 1. Convert a video file to an MP3 Converting a video file to an MP3 is a single FFmpeg command: ffmpeg -i video.mp4 audio.mp3 This is consistently the slowest step of the process, and I do wonder if I could use different settings or an alternative encoder to make it go faster – but it’s not slow enough to be worth further investigation. 2. Extract images from the video at a fixed interval Extracting images from a video needs a more complicated FFmpeg command: ffmpeg -i video.mp4 \ -vf 'fps=1/5,scale=iw*sar:ih,scale=min(iw\,945):min(ih\,945):force_original_aspect_ratio=decrease' \ thumbnail_%04d.jpg This extracts an image every 5 seconds, downscales any image larger than 945 pixels square (while preserving the original aspect ratio), and saves the results as sequentially numbered JPEG images (thumbnail_0001.png, thumbnail_0002.png, and so on). The key is the -vf flag, which defines two FFmpeg filters: The fps filter selects one frame every 5 seconds (fps=1/5). The first scale filter scales the width based on the sample aspect ratio (scale=iw*sar:ih). Without this filter, frames can be stretched and distorted. The second scale filter scales the input video, preserving the original aspect ratio (force_original_aspect_ratio=decrease), and ensuring the output images fit within 945×945px or the size of the input video, whichever is smaller. My limit is 945 pixels because that’s the largest size that cover art is shown on my iPhone. This filter still isn’t completely correct – it sometimes creates images from portrait videos that are smaller than I’m expecting – but it’s good enough. These are only thumbnails for glancing at, and if I want to change it later, I can always do the image resizing outside FFmpeg. 3. Insert the images as hidden chapters in the MP3 file Inserting the chapters into the MP3 file is more complicated. Although FFmpeg has basic support for ID3 metadata, as far as I know, it can’t insert chapters with per-chapter artwork. Instead, I’m going to reach for Python and the Mutagen library. Here’s the code to add a chapter to an MP3 file: from mutagen.id3 import APIC, CHAP, ID3, PictureType audio = ID3("audio.mp3") with open("thumbnail_0001.jpg", "rb") as f: img_data = f.read() image_frame = APIC(mime="image/jpeg", type=PictureType.OTHER, data=img_data) chapter_frame = CHAP( element_id="chp1", start_time=0, end_time=5 * 1000, sub_frames=[image_frame] ) audio.add(chapter_frame) audio.save() This creates a single chapter that lasts the first 5 seconds (0 to 5000 milliseconds), and the per-chapter cover art is thumbnail_0001.jpg. If we ran this in a loop, we could add images for every 5 second slice of the original video. This code is inserting two frames into the ID3 metadata: The CHAP (chapter) frame contains the timing information, and it can have subframes for metadata like title, chapter art, or associated URL. The APIC (attached picture) subframe contains information about a picture, which can either be a blob of image data or a URL to an image on the web. Normally, you’d also insert a CTOC frame which defines a table of contents, but I don’t want a TOC with hundreds of 5-second chapters, so I’m deliberately not doing this here. This is allowed by the ID3 spec – you’re not required to insert a CTOC frame if you’re using chapters, and you can have chapters that aren’t listed in your table of contents. To work out which frames I needed, I used Forecast to create some chapters by hand, and I inspected their frames. In particular, loading an MP3 and calling Mutagen’s pprint() method shows a human-readable list of frames, and then I could drill into the individual fields: from mutagen.id3 import ID3 audio = ID3("audio.mp3") print(audio.pprint()) I wrapped all this code in a project called glancecast, which allows you to convert a video file with a single command, with optional flags to set the frame length and chapter art size: $ python3 glancecast.py interesting_talk.mp4 interesting_talk.mp3 The process takes a minute or so to complete, most of which is spent transcoding the video file to MP3. The resulting MP3s are usually 40 to 50 MB in size, which is very reasonable. The outcome: How it looks in practice Here’s what one of these “glanceable” podcasts looks like in Overcast and on my lock screen: Maggie Appleton presented this talk over two years ago and it’s been on my “talks to watch” list ever since. Once I put it in Overcast? I listened to it in less than a day. It’s not a lot of extra information, but enough that I can quickly glance down and get the gist of what a speaker is saying. Both views update with a new frame every few seconds, or I can put my phone in my pocket and ignore the screen. I’ve used this approach for half a dozen videos so far, and I’m happy with the results. I expect to keep using it, because I have a long queue of videos I’ve been meaning to watch. If you’d like to try this, check out glancecast for the full code and instructions. [If the formatting of this post looks odd in your feed reader, visit the original article]

12 hours ago 1 votes
AI Isn’t Replacing Open Source

Andrew Baker, the current Group CIO at Capitec Bank wrote an interesting piece on AI and open source, and how these tools that generate code according to one’s specification may replace the general reliance on open source implementations done by contributors around the world. I’d really recommend reading it. I have great admiration and respectContinue reading "AI Isn’t Replacing Open Source"

19 hours ago 1 votes
Confessions of an Unrepentant Slop Snob

A framework for thinking about when AI involvement is additive or a violation

yesterday 1 votes
Planning with Agents: Divided Worlds, Boundary Objects, and Thicker Interfaces

Why we need richer, thicker interfaces and better boundary objects for collaborative planning with agents

yesterday 1 votes
Foundations of Agent Friendly Codebases

A look at 10 foundational pillars that enable agents to operate more competently and more efficiently in any codebase.

2 days ago 1 votes
📚 BoredReading

You seem to be enjoying this.

Join free to unlock everything.

Create free account

Already have an account? Sign in