tether-go

April 18, 2026 · View on GitHub

A rudimentary Go skeleton for writing trainers against offline games. The name's the point: features tether themselves to a running game process through a shared handle, and the whole thing detaches and shuts down cleanly when the game exits. Wires up keyboard hotkeys, watches a target process, and lets you drop in features (cheats) that read and write its memory via libmem.

Offline games only. If you use this on anything with anticheat you deserve whatever happens to you.

libmem-go

This project relies on libmem-go for all process attach and memory read/write work — it's a cgo wrapper around the native libmem C library. You must set libmem-go up first. See the Setup section for the full toolchain and platform notes.

Layout

Flat package, a file per concern:

  • main.go — flags, ctx, signal handling, feature list
  • events.go — tiny pub/sub keyed on keyboard keys, with panic recovery so one bad feature doesn't take the rest down
  • keys.go — key constants; the string values match what gohook expects
  • keyboard.go — only hooks the keys a feature actually subscribed to
  • heartbeat.go — polls for the game process by name, attaches, and kills the root context when the game exits
  • target.go — atomic-pointer wrapper around the libmem process handle, plus typed read/write helpers (U8/U16/U32/U64, F32/F64, bytes, deep pointers)
  • feature.go — the interface
  • feature_aimbot.go, feature_godmode.go — examples

Running

go run . --target game.exe

Hotkeys work right away. Memory writes are no-ops until the target shows up and the heartbeat attaches.

Flags:

  • --target — process name. Empty means no heartbeat, useful for poking at the event bus without a game running.
  • --heartbeat — poll interval, default 2s.

Stop it with Ctrl+C, or just close the game — the heartbeat sees the process go and shuts everything down.

Usage

You can use the example below and see how it all comes together. Granted, you should simply git clone this repo and make your own changes manually or with an AI agent (whatever floats your boat).

func main() {
	// you may edit this and set a fixed process name
	// instead of this cli argument
	targetName := flag.String("target", "", "name of target process to attach to (e.g. game.exe). If empty, heartbeat is disabled.")
	interval := flag.Duration("heartbeat", 2*time.Second, "target process poll interval")
	flag.Parse()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-sigCh
		logInfo("interrupt received; shutting down")
		cancel()
	}()

	// you can subscribe stuff to the bus

	bus := NewEventBus()
	target := &Target{}

	// kill the app using esc key
	// you can actually hook any key similarly to do stuff
	bus.Subscribe(KeyEsc, func() {
		logInfo("ESC Key Pressed. Exiting...")
		cancel()
	})

	// feature registration
	// any features you add
	// have to be registered here
	// using their struct names
	features := []Feature{
		&Aimbot{},
		&GodMode{},
	}

	for _, f := range features {
		f.Setup(ctx, bus, target)
		logInfo("feature loaded", "name", f.Name())
	}

	logInfo("started", "target", *targetName)

	if *targetName != "" {
		go WatchProcess(ctx, cancel, target, *targetName, *interval)
	} else {
		logWarn("no --target set; running without process heartbeat")
	}

	go StartKeyboardListener(ctx, bus)

	<-ctx.Done()
	logInfo("shutdown complete")
}

Setup

Needs Go 1.25+ and a cgo-capable C toolchain on the host. Both gohook and libmem-go are cgo wrappers, so CGO_ENABLED=1 (the default when a compiler is present) is required — you can't cross-compile this to a static binary.

For libmem itself, see https://github.com/alexanderthegreat96/libmem-go. There's a go run ./cmd/setup in that repo that builds the C library and drops it where the bindings expect it. Do that once before building this project.

Linux

  • Go 1.25+ and a cgo-capable C toolchain. Install the usual build tools:

    sudo apt install build-essential gcc
    
  • X11 / XKB dev headers required by gohook:

    sudo apt install libx11-dev libxtst-dev libx11-xcb-dev libxcb1-dev libxcb-xkb-dev libxkbcommon-dev libxkbcommon-x11-dev
    
  • If you're running under Wayland without X11, the keyboard hook may fail with XKB errors. The app now attempts a Linux input-device fallback on Wayland by reading /dev/input/* devices, but that requires permission to access the keyboard device nodes (typically root or input-group access).

  • On modern kernels with Yama enabled, cross-process ptrace is restricted, so libmem's reads and writes will fail silently. Either run the trainer as root, or loosen the policy:

    sudo sysctl kernel.yama.ptrace_scope=0
    

Windows

  • Go 1.25+ and a cgo-capable toolchain. The easiest setup is MSYS2.

  • Install MinGW GCC and make sure the MinGW bin directory is on PATH:

    pacman -S mingw-w64-x86_64-gcc
    
  • Windows does not need X11 headers; gohook uses the native WinAPI backend on Windows.

  • libmem-go still needs its native library built. In the libmem-go repo, run:

    go run ./cmd/setup
    

    Then place the resulting DLL next to the compiled trainer executable or somewhere on PATH.

  • Match the bitness of the trainer to the game: 64-bit trainer for 64-bit games, 32-bit trainer for 32-bit games. A mismatched build will attach and then silently fail every read/write.

Adding a feature

A feature is just a type that implements the Feature interface from feature.go:

type Feature interface {
    Name() string
    Setup(ctx context.Context, bus *EventBus, target *Target)
}

The runtime gives each feature three things and nothing else:

  • ctx — root context. When it's cancelled (Ctrl+C, ESC, the game exited, SIGTERM) every goroutine your feature spawned must return. Select on ctx.Done() in every loop.
  • bus — the keyboard event bus. Call bus.Subscribe(KeyX, fn) for each hotkey. Handlers run on the bus's goroutine with panic recovery, so a crash in one handler doesn't kill the others.
  • target — the shared game-process handle. It may be unattached at the moment Setup runs and at any point later (the game exits or restarts), so every tick must guard on target.Attached() (or check target.Process() != nil) before reading/writing.

1. Create feature_<name>.go

One file per feature, all in package main. The shape almost every feature ends up with:

package main

import (
    "context"
    "sync/atomic"
    "time"
)

type NoClip struct {
    enabled atomic.Bool
    target  *Target

    attachedPID uint32
    moduleBase  uintptr
}

func (n *NoClip) Name() string { return "noclip" }

func (n *NoClip) Setup(ctx context.Context, bus *EventBus, target *Target) {
    n.target = target

    // pick any key from keys.go — one feature, one hotkey is the convention
    bus.Subscribe(KeyF3, func() {
        now := !n.enabled.Load()
        n.enabled.Store(now)
        logInfo("noclip toggled", "enabled", now)
    })

    go n.run(ctx)
}

func (n *NoClip) run(ctx context.Context) {
    ticker := time.NewTicker(50 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if !n.enabled.Load() {
                continue
            }
            n.tick()
        }
    }
}

func (n *NoClip) tick() {
    p := n.target.Process()
    if p == nil {
        return // game not attached yet — just skip
    }

    // cache module base once per PID; re-resolve if the game restarts
    if n.attachedPID != p.PID {
        // libmem.FindModuleEx(p, p.Name) — see feature_godmode.go for an example
        n.attachedPID = p.PID
    }

    // reads/writes via target.ReadU32 / target.WriteF32 / target.DeepPointer …
}

Rules of thumb the skeleton encodes:

  • State on the struct, not globals. Hotkey toggles flip an atomic.Bool so the tick loop can check it without a mutex.
  • Gate every tick on the process. target.Process() returning nil is the normal steady state before the game launches — log nothing, just return.
  • Cache the module base per-PID. Resolving it every tick is wasteful, and PIDs can change if the user restarts the game without restarting the trainer.
  • Never block. No time.Sleep in Setup, no long work in a bus handler — kick everything onto the run goroutine.
  • Honor the context. Every select must include case <-ctx.Done(): return, otherwise the process won't shut down cleanly.

See feature_godmode.go for the minimal write-only case and feature_aimbot.go for one that walks pointer chains and does per-tick math.

2. Register it in main.go

Features are not auto-discovered. Add an instance to the features slice in main.go:

features := []Feature{
    &Aimbot{},
    &GodMode{},
    &NoClip{}, // <-- new feature goes here
}

That's the whole registration step. The for _, f := range features loop below it calls Setup on each one with the shared ctx, bus, and target, and logs feature loaded name=<Name()>.

3. Run it

go run . --target game.exe

You should see feature loaded name=noclip in the startup log. Press your hotkey — the toggle message should appear. If the hotkey does nothing, the key constant in keys.go may not match what gohook reports on your platform; add a temporary logInfo at the top of keyboard.go's dispatch to see the raw key strings.

Available memory helpers

All on *Target (target.go):

  • ReadU8/U16/U32/U64, ReadF32/F64, ReadBytes — all return (value, ok); ok == false means unattached or the read failed, don't panic on it.
  • WriteU8/U16/U32/U64, WriteF32/F64, WriteBytes — return bool.
  • DeepPointer(base, []offsets) — walks a pointer chain via libmem.
  • Process() — raw *libmem.Process for anything the helpers don't cover (module lookups, pattern scans, hooks, etc.).