bHaptics TactSuit haptic vests on Linux — no Windows, no bHaptics Player.
A userspace daemon that talks to the vest directly over Bluetooth LE and emulates the bHaptics Player APIs, so games, mods, VRChat, and your own scripts can drive the vest on Linux. Verified on a TactSuit Pro; the TactSuit X40 and Air speak the same protocol and should work (untested).
bHaptics only ships a Windows Player app, and it does not run under Wine. The vest itself, though, is just a BLE device with a reverse-engineered protocol: a Nordic-UART-style GATT service where 20-byte writes set 40 motor intensities. Everything the Player does can be done natively — so this project does it.
- SDK1 emulation —
ws://127.0.0.1:15881/v2/feedbacks. Community game mods and older native integrations connect and just work (including Windows games under Proton — localhost passes through). - SDK2 emulation —
wss://127.0.0.1:15882/v3/feedback(self-signed TLS; SDK2 clients skip validation by design). Handles auth,SdkPlayDotMode,SdkPlaywith event definitions learned from the game or fetched from bHaptics' public definitions API — both.tact-style patterns and the audio-authored clips newer games ship. Events are looked up per game, so two games that both definedeatheach get their own. - VRChat OSC bridge —
udp://127.0.0.1:9001, officialbOSC/v2/VestFront|VestBack/0-19avatar parameter convention. - Audio-to-haptics — multi-band DSP on any PipeWire source: bass → stereo rumble, 1–10 kHz onsets (sword hits, gunshots) → sharp taps on the upper chest. Optional frequency spread maps sub-bass / bass / low-mids to vest rows (deepest at the belly, mids at the chest) so frequency becomes position. Otherwise games only by default: the vest binds to whichever game starts making sound and stays silent the rest of the time, so Discord, music and browser audio never buzz you (pick System output to capture everything). Auto-pauses whenever a real haptics client connects.
- Learns each game — tune the vest while a game plays and those settings
are remembered for that app, reapplied next session with no preset to name.
Promote a good one to a named preset in one click, or clear the memory with
vestctl game --forget. Explicit presets still win where you make one. - Feel tuning — one output stage shapes everything the vest plays: master
strength, a minimum level so weak effects don't vanish below what the coin
motors can start at, smooth (dithers between the vest's 16 power steps
over time for ~4x finer fades, on by default) and punch (a 20 ms kick
when a motor starts, for crisper hits). Path effects glide between
keyframes over a soft spot of nearby motors instead of hopping cell to
cell. UI "Feel" card with a feel test, or
vestctl feel --strength 120 --floor 2 --punch on --test. - Tray quick control — optional
bhaptics-trayputs live vest status in your system tray with one-click mute/arm, stop-all, and open-UI, so you don't have to keep a browser tab around.install.shsets it up (it uses the systempython-gobjectfor the tray icon); packages listpython-pystrayas optional. - Idle auto-off — after 15 minutes with no haptics the daemon disconnects
the vest to save its battery (it then powers itself off). It reconnects the
moment a game connects, sends a pattern, or the audio it follows makes
noise again — or when you switch the vest back on, click the status pill in
the UI, pick Wake in the tray, or run
vestctl wake. - Pause / Stop all — Stop all silences everything the daemon is driving right now (key/frame effects, OSC and audio mode), and Disable daemon stops it serving game clients entirely while leaving the vest link and this UI up. That is the control to reach for when a game's bundled SDK client crashes while the daemon is connected (Half-Life 2: VR Mod does). While disabled, a game's connection is refused with HTTP 503 rather than accepted and left hanging, so it reads as "no Player running", and the switch survives a restart.
- Auto per-game presets — name a preset exactly like the app in the
source list (case-insensitive) and it applies itself, bound to that app's
audio, the moment the game makes sound; your previous setup is restored
when the game exits. No preset? Any new app that isn't an obvious
non-game (browsers, Discord, media players, SteamVR itself) gets audio
mode bound to it automatically — with the settings you last tuned for it,
or your current ones the first time — so unsupported games just work with
zero setup. Detection matches a stream's process binary too, not just its
display name, so a Discord/browser voice process can't masquerade as a game
(it reports "WEBRTC VoiceEngine" while running as
Discord), and installed Steam game names are preferred — a real game taking over from some generic stream binds automatically. Toggle in the UI,vestctl auto on|off, or arm everything at once withvestctl game on|off|toggle. Desktop notifications say when the vest connects and which game got picked up (toggleable in the UI). A game mod that connects but never actually sends patterns no longer silences audio haptics — the vest only yields while a game is really driving it. - Mod patterns — import
.tactfiles, SDK2 definition manifests (the JSON Unity/Unreal mods ship), or whole mod folders in the UI or withvestctl import <dir>; everything is pre-registered at startup under both SDK1 keys and SDK2 event names, so mods that play patterns by name just work. When a game asks for a pattern you don't have, its name shows up in the UI so you know exactly what to import. SDK2 event definitions learned from games are cached across restarts and replayable from the UI. - Web UI —
http://127.0.0.1:15881/ui: live motor visualization, battery, connected games, audio meter/tuning, an interactive motor-mapping wizard, and a multi-frame effect designer. - Generic haptics for unmodded Unity games —
vestctl doctor <game> --generic --fixinstalls the bundled VestRumble BepInEx plugin: it hooks the game's controller-rumble calls (SteamVR, Oculus, Unity XR, new Input System) and mirrors them to the vest as side-aware chest taps. Pairs with audio mode for a surprisingly complete fake integration. Mono Unity only for now; refuses anti-cheat games outright. - Gamepad rumble mirror — for flat games: the daemon wraps your
FF-capable gamepad in a virtual copy; rumble still reaches the pad and
buzzes the belly/mid rows. No game files involved at all. Toggle in the
UI or
vestctl pad on|off(needs writable/dev/uinput; Steam setups usually have it). vestctlCLI —vestctl status | pulse | stop | audio | auto | game | feel | pad | preset | effect | play | import | sources | doctor | protonfor scripts and hotkeys.vestctl game onarms games-only auto,vestctl game offmutes everything — bind it in your desktop for a one-key haptics suspend.
Requirements: Linux with BlueZ + PipeWire (pactl/parec), Python 3.10+,
openssl. Vest paired-free — just powered on.
git clone <this repo> && cd bhaptics-linux
./install.shThat creates a venv, installs deps (bleak, websockets, numpy), and sets up a
bhaptics-daemon systemd user service that starts at login and auto-connects
to any BLE device named TactSuit*/Tactot*. Re-run install.sh after
moving the folder. Open the UI, click a grid cell, feel the buzz.
Because it installs a user service, run it from a terminal inside your
graphical login session as your normal user — not over SSH and not with
sudo. (On SteamOS, run it from Desktop Mode; the installer repairs a
nested-session XDG_RUNTIME_DIR automatically.) Note that SteamOS's
read-only rootfs means the makepkg route below won't survive an OS update.
Settings live in ~/.config/bhaptics-linux/ (mapping, presets, learned
per-app settings, effects, patterns) and ~/.local/state/bhaptics-linux/
(SDK2 cert + event cache); old in-repo state migrates automatically on first
start.
On Arch(-based) distros you can install system-wide instead:
cd packaging && makepkg -si (disable the install.sh user unit first if
you had one).
If effects land on wrong body spots, run the mapping wizard in the UI (the TactSuit Pro has 32 motors behind a 40-slot protocol; slots 32–39 are dead).
One command diagnoses a game's whole bhaptics mod setup — plugin placement (BepInEx/plugins), the client library, the Wine prefix trick, local pattern files — and fixes what it can:
vestctl doctor <appid | name | path> # diagnose + propose
vestctl doctor <appid | name | path> --fix # apply the fixes
vestctl doctor --all --fix # one shot for the whole libraryThe 🩺 Detect games button in the UI lists every installed game with
bhaptics support and its patch status — ✓ ready, or what's missing — with a
per-game Fix button, so nothing touches a game folder without you
clicking it. It distinguishes mods from built-in bhaptics support (many VR
titles ship the SDK) and native Linux builds (which need nothing). A game that
merely ships the SDK is not called ready: several titles ship it and never
call it (Arken Age does — bHaptics lists it as Mod support, so it needs its
community mod), and the doctor says so instead of guessing. Loader DLL overrides
(BepInEx→winhttp, ASI→winmm, MelonLoader→version, UE4SS→dwmapi) are baked
into the prefix registry, so no WINEDLLOVERRIDES launch option is
needed; most mods also auto-start our fake Player stub, so usually no
launch options at all. If a mod doesn't, add
tools/proton-wrap.sh %command% — the doctor reads Steam's own config, flags
the launch option when it isn't set, and --fix writes it in for you. Steam
keeps that file in memory and rewrites it on exit, so a change made while Steam
is running is only trusted after a run with Steam closed: until then the doctor
keeps reporting it instead of claiming the game is ready. Background: bhaptics_library.dll
refuses to connect unless it believes the Windows Player is installed and
running — the doctor fakes the registry entry and drops a stub exe
(vestctl proton <appid> does just that part). Nothing here injects into
game processes; doctor only places mod files and writes prefix registry
keys — and it refuses to touch any game containing EasyAntiCheat/BattlEye.
For Unity games with no bhaptics support at all:
vestctl doctor <game> --generic --fixinstalls BepInEx (pinned official release, SHA-256 verified, added files
only) plus the bundled VestRumble plugin — controller rumble becomes vest
taps. The plugin source lives in plugin/VestRumble/ (~200 lines, builds
with dotnet build); tune strength per game in
BepInEx/config/org.bhaptics-linux.vestrumble.cfg.
The daemon binds loopback only — matching the official Player — so a game running on a different PC (Steam Remote Play, Steam Link) can't reach it. Steam's streaming carries video, audio and controller input, never the game's haptics API calls, so native haptics need a bridge. Two pieces:
On the vest's device, let the daemon listen on the LAN by setting the bind address and restarting it:
mkdir -p ~/.config/bhaptics-linux
echo '{"bind": "0.0.0.0"}' > ~/.config/bhaptics-linux/network.json
systemctl --user restart bhaptics-daemonBHAPTICS_BIND in the environment overrides that file for manual runs; to set
it on the service instead, add Environment=BHAPTICS_BIND=0.0.0.0 via
systemctl --user edit bhaptics-daemon. The web UI stays loopback-only
regardless. On the gaming PC, run the relay so unmodified games still find
the Player at 127.0.0.1 — it auto-discovers the device on the LAN:
tools/remote-relay.py # finds the device automatically
tools/remote-relay.py --list # show what's on the LAN
tools/remote-relay.py --host <ip> # fallback when broadcast can't cross (VLANs)Alternatively the gaming PC's own daemon can host: pick Host haptics in the
web UI (or write {"relay_host": true} to ~/.config/bhaptics-linux/network.json)
and it forwards the games on that machine to the vest device, which only needs
Accept. Hosting releases that machine's own BLE link—the vest allows a single
connection, so the two daemons must not both hold it—and the UI shows whether the
remote device actually has a vest connected. Turn Host on before launching the
game: switching mid-session drops the game's connection and relies on its SDK
client reconnecting, which Source mods and some Unity titles don't do.
Whichever route you take, patterns and settings live on the daemon that owns
the vest: with the relay that's the vest device, and with Host mode the remote
one, so import .tact files there (vestctl import on the gaming PC only
affects the local daemon).
Some native titles gate every effect on their device list and refuse to send
anything until it has a vest in it (Dungeons of Eternity is one). When the vest
sits on another device that list is filled from the remote, but if a game still
says it can't find your vest, the always tell games a vest is connected
switch in the UI (or {"report_vest": true} in network.json) makes the
emulated Player answer that question with a vest anyway. It only changes what
games are told — the UI, vestctl status and the audio engine keep showing the
truth — and it is off by default.
VRChat needs no relay: point its OSC output straight at the daemon with the
launch option --osc=9000:<vest-device-ip>:9001.
The SDK and OSC endpoints are unauthenticated (as is the official Player), so bind beyond loopback only on a trusted network and firewall the ports if in doubt. Bridged haptics also arrive slightly before the streamed video (the video is still buffered), so they can feel early — audio mode stays in sync because it taps the already-delayed audio.
This project was built in one long session with Claude Code (Anthropic's AI coding agent) doing the research, protocol archaeology, coding and debugging, while a human supplied the actual vest, pressed the buttons, and reported what their torso felt. Every protocol path was verified against real hardware — BLE writes, both SDK sockets, a real SDK2 game mod under Proton, OSC, and the audio DSP. It exists because a TactSuit owner on Linux was tired of the vest being a Windows-only accessory. Read the code before trusting it with your hardware; that's good advice for human-written code too.
ruff check .
python -m unittest discover -s tests -t . # protocol, audio, sdk2, feel, tray…CI runs ruff, a compile/import check, the suite, the ui.html JS parse, and
builds the Arch package.
- SenseShift — original reverse-engineering of the bHaptics BLE protocol (GATT UUIDs, frame format). This project would not exist without it.
- freehaptics (LGPL-3.0) — Rust library for bHaptics devices; the default X40 dot-grid mapping tables were adapted from it, and its source was an invaluable protocol reference.
- godot-bhaptics-native (LGPL-3.0) — protocol reference.
- bhaptics/tact-simhub — readable official SDK2 client, used as the wire-format reference for the SDK2 emulation.
- bhaptics/VRChatOSC — the OSC parameter convention.
Everything here is either original, public protocol knowledge, or license-compliant reuse:
- No bHaptics proprietary code is included or was copied. The SDK1/SDK2 wire formats were learned from bHaptics' own public GitHub repositories (tact-simhub, VRChatOSC, tact-js) and from observing what their client library sends over the wire — standard interoperability analysis.
- The default X40 mapping tables were adapted from freehaptics (LGPL-3.0), which this project's GPL-3.0 license is compatible with.
- SenseShift's protocol documentation (GATT UUIDs, frame format) is used as published reverse-engineering research, with credit.
- One source we deliberately did not use: a blog post on this topic whose author forbids AI tools from reading it. It was skipped entirely; all protocol work here traces to the code repositories above.
Not affiliated with or endorsed by bHaptics Inc. "bHaptics" and "TactSuit" are trademarks of their respective owner. This is an interoperability project; use at your own risk.
GPL-3.0-or-later — every derivative must remain open source with full
published source code, which also makes selling closed versions impossible.
Chosen deliberately: it's the strongest protection against closed/commercial
capture that remains compatible with the LGPL-3.0 material this project
builds on. See LICENSE.