DEV Community

Cover image for How to Build Your Own AI Mascot in Golang.
Sk
Sk

Posted on

How to Build Your Own AI Mascot in Golang.

Uuhm, so Grok just unleashed an anime “waifu” mascot and the internet is losing its mind. Wild, I know.
It reminded me of the mascot I built for my Golang kernel assistant, definitely not a waifu, just a friendly gopher like buddy.

mascot

The cool part? With Go + C + OpenGL it’s surprisingly painless to do the same yourself.

By the end of this walkthrough you’ll have a draggable, clickable, dancing mascot on your desktop, and a roadmap for taking it even further (because OpenGL is C).

not waifu

Heads‑up: I grabbed the only decent free 2‑D dancing sprite I could find on the internet: Dancing Girl. Feel free to swap in anything you like.


Repo

git clone https://github.com/sklyt/mascot.git
Enter fullscreen mode Exit fullscreen mode

1. Setting Up

Create a fresh Go module and pull in the OpenGL bindings:

go mod init github.com/mascot

go get -u github.com/go-gl/gl/v4.1-core/gl
go get -u github.com/go-gl/glfw/v3.3/glfw
Enter fullscreen mode Exit fullscreen mode

Optional (legacy / GLES testing):

go get -u github.com/go-gl/gl/v4.1-compatibility/gl
go get -u github.com/go-gl/gl/v3.1/gles2
go get -u github.com/go-gl/gl/v2.1/gl
Enter fullscreen mode Exit fullscreen mode

2. Create the Mascot Skeleton

main.go

package main

func main() {
    // we’ll wire this up in a sec
}
Enter fullscreen mode Exit fullscreen mode

Project layout:

mascot/
  shaders/   <- how we tell the GPU to draw stuff
  glf.go     <- OpenGL window + mascot logic
Enter fullscreen mode Exit fullscreen mode

3. Shaders 101 (a very quick crash course)

Think of shaders as assembly for your GPU: one tiny program decides where each vertex goes, another decides what color each pixel should be.

      CPU                GPU
   ┌────────┐      ┌──────────────┐
   │ Go code│ ---> │  Vertex      │
   │        │      │  Shader      │
   └────────┘      └────┬─────────┘
                         │positions
                         ▼
                   ┌──────────────┐
                   │ Fragment     │
                   │ Shader       │
                   └────┬─────────┘
                         │colors
                         ▼
                     Screen 🔆
Enter fullscreen mode Exit fullscreen mode

If you want the deeper theory later, bookmark The Book of Shaders.

shaders/quad.frag – how pixels get painted

#version 330 core
in vec2 vUV;
out vec4 FragColor;
uniform sampler2D uTex;

void main() {
    FragColor = texture(uTex, vUV);
}
Enter fullscreen mode Exit fullscreen mode

shaders/quad.vert – where vertices live

#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aUV;

out vec2 vUV;

void main() {
    vUV = aUV;
    gl_Position = vec4(aPos, 0.0, 1.0);
}
Enter fullscreen mode Exit fullscreen mode

4. Spinning up the OpenGL Window

glf.go

package mascot

import (
    "embed"
    "fmt"
    "image"
    "image/png"
    "os"
    "runtime"
    "sync"
    "time"

    "github.com/go-gl/gl/v4.1-core/gl"
    "github.com/go-gl/glfw/v3.3/glfw"
)

//go:embed shaders/*
var shaders embed.FS

// DesktopMascot is a transparent GLFW window with an animated sprite.
type DesktopMascot struct {
    window     *glfw.Window
    textures   []uint32
    frameDur   time.Duration
    frames     int
    shaderProg uint32
    vao, vbo   uint32
    OnClick    func()
}

var (
    instance *DesktopMascot
    once     sync.Once
)

// GetMascot returns a singleton mascot (safe to call multiple times).
func GetMascot(framePaths []string, frameRate float64) *DesktopMascot {
    once.Do(func() {
        m, err := newDesktopMascot(framePaths, frameRate)
        if err != nil {
            panic(err)
        }
        instance = m
    })
    return instance
}
Enter fullscreen mode Exit fullscreen mode

Inside newDesktopMascot

  1. Init GLFW & OpenGL.
  2. Hint the window (transparent, always on top, borderless).
  3. Create the window; park it bottom‑left.
  4. Compile shaders and bind a simple quad.
  5. Load textures for each animation frame.
func newDesktopMascot(framePaths []string, frameRate float64) (*DesktopMascot, error) {
    runtime.LockOSThread()

    if err := glfw.Init(); err != nil {
        return nil, fmt.Errorf("failed to init GLFW: %w", err)
    }

    // Window hints <- Define Window Behavior 
    glfw.WindowHint(glfw.ContextVersionMajor, 3)
    glfw.WindowHint(glfw.ContextVersionMinor, 3)
    glfw.WindowHint(glfw.OpenGLProfile, glfw.OpenGLCoreProfile)
    glfw.WindowHint(glfw.TransparentFramebuffer, glfw.True)
    glfw.WindowHint(glfw.Floating, glfw.True)   // always on top
    glfw.WindowHint(glfw.Decorated, glfw.False) // no borders
    glfw.WindowHint(glfw.Resizable, glfw.False)

    // Create & place window
    win, err := glfw.CreateWindow(200, 250, "Mascot", nil, nil)
    if err != nil {
        glfw.Terminate()
        return nil, fmt.Errorf("failed to create window: %w", err)
    }
    mon := glfw.GetPrimaryMonitor()
    x, y := mon.GetPos()
    mode := mon.GetVideoMode()
    win.SetPos(x, y+mode.Height-300) // bottom‑left
    win.MakeContextCurrent()
    glfw.SwapInterval(1)

    if err := gl.Init(); err != nil {
        return nil, fmt.Errorf("failed to init GL: %w", err)
    }
    gl.ClearColor(0, 0, 0, 0)

    // Compile shaders
    vertSrc, _ := shaders.ReadFile("shaders/quad.vert")
    fragSrc, _ := shaders.ReadFile("shaders/quad.frag")
    program, err := newProgram(string(vertSrc), string(fragSrc))
    if err != nil {
        return nil, err
    }
    gl.UseProgram(program)
    gl.Uniform1i(gl.GetUniformLocation(program, gl.Str("uTex\x00")), 0)

    // Quad geometry
    var vao, vbo uint32
    setupQuad(&vao, &vbo)

    // Sprite frames
    textures, err := loadTextures(framePaths)
    if err != nil {
        return nil, err
    }

    return &DesktopMascot{
        window:     win,
        textures:   textures,
        frameDur:   time.Second / time.Duration(frameRate),
        frames:     len(textures),
        shaderProg: program,
        vao:        vao,
        vbo:        vbo,
    }, nil
}

// newProgram, setupQuad, loadTextures … (helper funcs coming next)
Enter fullscreen mode Exit fullscreen mode

And that’s the backbone!


Helper Functions

Let’s wrap up the last bits, these are the building blocks that bring your mascot to life.


newProgram: Compile the shaders

This function compiles the vertex and fragment shaders. Don’t stress about the internals if shaders are new to you, black box it for now, trust your intuition, and circle back later.

func newProgram(vertSrc, fragSrc string) (uint32, error) {
    compile := func(src string, shaderType uint32) (uint32, error) {
        s := gl.CreateShader(shaderType)
        csources, free := gl.Strs(src + "\x00")
        defer free()
        gl.ShaderSource(s, 1, csources, nil)
        gl.CompileShader(s)

        var status int32
        gl.GetShaderiv(s, gl.COMPILE_STATUS, &status)
        if status == gl.FALSE {
            var logLen int32
            gl.GetShaderiv(s, gl.INFO_LOG_LENGTH, &logLen)
            log := string(make([]byte, logLen))
            gl.GetShaderInfoLog(s, logLen, nil, gl.Str(log+"\x00"))
            return 0, fmt.Errorf("shader compile error: %s", log)
        }
        return s, nil
    }

    vs, err := compile(vertSrc, gl.VERTEX_SHADER)
    if err != nil {
        return 0, err
    }
    fs, err := compile(fragSrc, gl.FRAGMENT_SHADER)
    if err != nil {
        return 0, err
    }

    prog := gl.CreateProgram()
    gl.AttachShader(prog, vs)
    gl.AttachShader(prog, fs)
    gl.LinkProgram(prog)

    var stat int32
    gl.GetProgramiv(prog, gl.LINK_STATUS, &stat)
    if stat == gl.FALSE {
        var logLen int32
        gl.GetProgramiv(prog, gl.INFO_LOG_LENGTH, &logLen)
        log := string(make([]byte, logLen))
        gl.GetProgramInfoLog(prog, logLen, nil, gl.Str(log+"\x00"))
        return 0, fmt.Errorf("program link error: %s", log)
    }

    gl.DeleteShader(vs)
    gl.DeleteShader(fs)
    return prog, nil
}
Enter fullscreen mode Exit fullscreen mode

setupQuad: Define the drawing space

This sets up the geometry, the little canvas your mascot dances on.

func setupQuad(vao, vbo *uint32) {
    vertices := []float32{
        -1, -1, 0, 1,  // Bottom-left
         1, -1, 1, 1,  // Bottom-right
         1,  1, 1, 0,  // Top-right
        -1,  1, 0, 0,  // Top-left
    }
    gl.GenVertexArrays(1, vao)
    gl.GenBuffers(1, vbo)
    gl.BindVertexArray(*vao)
    gl.BindBuffer(gl.ARRAY_BUFFER, *vbo)
    gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)

    // position attribute
    gl.EnableVertexAttribArray(0)
    gl.VertexAttribPointer(0, 2, gl.FLOAT, false, 4*4, gl.PtrOffset(0))

    // uv attribute
    gl.EnableVertexAttribArray(1)
    gl.VertexAttribPointer(1, 2, gl.FLOAT, false, 4*4, gl.PtrOffset(2*4))
}
Enter fullscreen mode Exit fullscreen mode

loadTextures: Bring your sprite to life

Loads each sprite frame from disk and pushes it to the GPU.

func loadTextures(paths []string) ([]uint32, error) {
    var ids []uint32

    for _, p := range paths {
        f, err := os.Open(p)
        if err != nil {
            return nil, err
        }
        img, err := png.Decode(f)
        f.Close()
        if err != nil {
            return nil, err
        }

        rgba := image.NewRGBA(img.Bounds())
        for y := 0; y < rgba.Rect.Dy(); y++ {
            for x := 0; x < rgba.Rect.Dx(); x++ {
                rgba.Set(x, y, img.At(x, y))
            }
        }

        var tex uint32
        gl.GenTextures(1, &tex)
        gl.BindTexture(gl.TEXTURE_2D, tex)
        gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
        gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
        gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA,
            int32(rgba.Rect.Dx()), int32(rgba.Rect.Dy()), 0,
            gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(rgba.Pix))

        ids = append(ids, tex)
    }
    return ids, nil
}
Enter fullscreen mode Exit fullscreen mode

Running the Mascot

Let’s attach the final methods to your DesktopMascot struct.

Cleanup & Destroy

func (m *DesktopMascot) clean() {
    defer gl.DeleteProgram(m.shaderProg)
    defer gl.DeleteVertexArrays(1, &m.vao)
    defer gl.DeleteBuffers(1, &m.vbo)

    for _, tex := range m.textures {
        gl.DeleteTextures(1, &tex)
    }
}

func (m *DesktopMascot) Close_() {
    m.clean()
    m.window.Destroy()
}
Enter fullscreen mode Exit fullscreen mode

The Run Loop

This is where the magic happens, render loop + click events + drag-to-move.

func (m *DesktopMascot) Run() {
    defer m.clean()

    if m == nil {
        panic("DesktopMascot is nil; ensure NewDesktopMascot did not return an error")
    }

    var dragStart struct {
        x, y   float64
        active bool
    }

    // Drag with right-click
    m.window.SetCursorPosCallback(func(w *glfw.Window, x, y float64) {
        if dragStart.active {
            winX, winY := w.GetPos()
            w.SetPos(winX+int(x-dragStart.x), winY+int(y-dragStart.y))
        }
    })

    // Click and Drag Events
    m.window.SetMouseButtonCallback(func(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mods glfw.ModifierKey) {
        if button == glfw.MouseButtonLeft && action == glfw.Press {
            if m.OnClick != nil {
                m.OnClick()
            }
        }
        if button == glfw.MouseButtonRight {
            if action == glfw.Press {
                dragStart.active = true
                dragStart.x, dragStart.y = w.GetCursorPos()
            } else {
                dragStart.active = false
            }
        }
    })

    start := time.Now()
    m.window.Show()

    for !m.window.ShouldClose() {
        delta := time.Since(start)
        frame := int(delta / m.frameDur % time.Duration(m.frames))

        gl.Clear(gl.COLOR_BUFFER_BIT)
        gl.UseProgram(m.shaderProg)
        gl.BindVertexArray(m.vao)
        gl.ActiveTexture(gl.TEXTURE0)
        gl.BindTexture(gl.TEXTURE_2D, m.textures[frame])
        gl.DrawArrays(gl.TRIANGLE_FAN, 0, 4)

        m.window.SwapBuffers()
        glfw.PollEvents()
    }

    glfw.Terminate()
}
Enter fullscreen mode Exit fullscreen mode

Running the Mascot in main.go

Wire everything together:

package main

import mascot "github.com/mascot/maskot"

func main() {
    win := mascot.GetMaskot([]string{
        // paths to your sprite frames
        // "C:/[path]/mascot1.png",
        // "C:/[path]/mascot2.png",
        // "C:/[path]/mascot3.png",
    }, 8) // 8 FPS animation

    win.OnClick = func() {
        // Optional click logic here
    }

    win.Run()
}
Enter fullscreen mode Exit fullscreen mode

Then just run it:

go run .
Enter fullscreen mode Exit fullscreen mode

That’s a Wrap!

Congrats! You’ve built your own starter mascot from scratch.
Want to build a full-blown customization studio?
Throw in a drag-and-drop UI, a plugin system, maybe even a voice or LLM module, OpenGL + Go gives you all the room to play.

Sky's the limit
Let me know what you build!

Top comments (1)

Collapse
 
sfundomhlungu profile image
Sk

Context Tweet(grok mascot):