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 listevents.go— tiny pub/sub keyed on keyboard keys, with panic recovery so one bad feature doesn't take the rest downkeys.go— key constants; the string values match what gohook expectskeyboard.go— only hooks the keys a feature actually subscribed toheartbeat.go— polls for the game process by name, attaches, and kills the root context when the game exitstarget.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 interfacefeature_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
ptraceis 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
bindirectory is onPATH:pacman -S mingw-w64-x86_64-gcc -
Windows does not need X11 headers;
gohookuses the native WinAPI backend on Windows. -
libmem-gostill needs its native library built. In the libmem-go repo, run:go run ./cmd/setupThen 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 onctx.Done()in every loop.bus— the keyboard event bus. Callbus.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 momentSetupruns and at any point later (the game exits or restarts), so every tick must guard ontarget.Attached()(or checktarget.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.Boolso 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, justreturn. - 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.SleepinSetup, no long work in a bus handler — kick everything onto therungoroutine. - Honor the context. Every
selectmust includecase <-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 == falsemeans unattached or the read failed, don't panic on it.WriteU8/U16/U32/U64,WriteF32/F64,WriteBytes— returnbool.DeepPointer(base, []offsets)— walks a pointer chain via libmem.Process()— raw*libmem.Processfor anything the helpers don't cover (module lookups, pattern scans, hooks, etc.).