1. Overview
What this setup does
This guide shows how to run an ETLegacy TV-Bot on a Linux server and stream your Wolfenstein: Enemy Territory server to Twitch – without any desktop session, using only terminal + ffmpeg.
Components
- ETLegacy client running as TV-Bot.
- ffmpeg encoding game window to Twitch.
- tvbot_supervisor.sh:
- Starts ETL + stream.
- Restarts both if something crashed.
- Monitors ET log for disconnects.
Goals
- 24/7 stream of your ET server.
- No root required, everything under user
tvbot. - One single script to
start,stop,restartandstatus.
2. Requirements
Environment
- Linux server with:
- ETLegacy client installed (64-bit recommended).
- ffmpeg installed from distro repo or static build.
- User account
tvbot(no root needed for running).
- Working Enemy Territory server you want to spectate.
- Twitch account and your stream key (keep it secret!).
Tip: Test everything in a screen/tmux session first.
Once it runs stable, you can rely on the supervisor script to keep it alive.
3. Directory Layout
Where files live
3.1 Home of the tvbot user
/home/tvbot/
etlegacy/
etl (ETLegacy binary)
etl.sh (TV-Bot start script)
tvbot_supervisor.sh (Supervisor + watchdog)
etltv.pid (PID file for ETL client)
etltv.log (ETL console log)
twitch_stream.pid (PID file for ffmpeg)
twitch_stream.log (ffmpeg log)
tw.sh (Twitch stream script)
3.2 Script overview
Scripts
File
Role
Notes
etl.sh
Start ETLegacy TV client
Runs ETL as spectator, connects to your server and logs to etltv.log.
tw.sh
Start ffmpeg Twitch stream
Captures ETL window / Xvfb and pushes to Twitch ingest.
tvbot_supervisor.sh
Supervisor & watchdog
Start/stop/restart/status & auto-restart on crashes/disconnects.
4. Step 1: Create tvbot user
One-time setup
Run as root once:
adduser --disabled-password --gecos "ET TV Bot" tvbot
# or:
# useradd -m tvbot
# switch to tvbot
su - tvbot
Inside tvbot home, create the folder:
mkdir -p /home/tvbot/etlegacy
cd /home/tvbot/etlegacy
Copy your ETLegacy binary + game data into this directory (or symlink it). Make sure ETL runs:
./etl +set fs_game etmain +quit
Note: This guide assumes ETLegacy binary is called
etl
and lives in /home/tvbot/etlegacy. If your path or name is different,
adjust scripts accordingly.
5. Step 2: ETLegacy TV client (etl.sh)
Start TV-Bot as spectator
Create /home/tvbot/etlegacy/etl.sh:
#!/bin/bash
# etl.sh – Start ETLegacy TV client for streaming
# User: tvbot (no root)
set -u
BASE_DIR="/home/tvbot/etlegacy"
ETL_BIN="${BASE_DIR}/etl"
PIDFILE="${BASE_DIR}/etltv.pid"
LOGFILE="${BASE_DIR}/etltv.log"
# Server you want to spectate
SERVER_IP="84.200.135.3"
SERVER_PORT="27960"
# Profile / config (adjust as needed)
PROFILE="tv"
CFG_EXEC="exec bot.cfg"
cd "${BASE_DIR}" || exit 1
# don't start if already running
if [ -f "${PIDFILE}" ]; then
pid="$(cat "${PIDFILE}" 2>/dev/null || echo "")"
if [ -n "${pid}" ] && kill -0 "${pid}" 2>/dev/null; then
echo "ETL already running (pid ${pid})"
exit 0
fi
fi
echo "Starting ETLegacy TV client ..."
# You can also run this in Xvfb if needed
nohup "${ETL_BIN}" \
+set fs_game "etmain" \
+set cl_profile "${PROFILE}" \
+set com_maxfps "30" \
+set cg_drawfps "1" \
+set vid_restart "1" \
+connect "${SERVER_IP}:${SERVER_PORT}" \
+${CFG_EXEC} \
>>"${LOGFILE}" 2>&1 &
echo $! > "${PIDFILE}"
echo "ETL started (pid $(cat "${PIDFILE}"))"
Make it executable:
chmod +x /home/tvbot/etlegacy/etl.sh
Advice: Keep the ETL config for this TV-Bot profile as lean as possible
(low resolution, stable 30 FPS, auto-spectate script, etc.).
6. Step 3: Twitch stream script (tw.sh)
ffmpeg to Twitch
Create /home/tvbot/tw.sh (outside of etlegacy/):
#!/bin/bash
# tw.sh – Start Twitch stream via ffmpeg
# User: tvbot
set -u
PIDFILE="$HOME/twitch_stream.pid"
LOGFILE="$HOME/twitch_stream.log"
# Twitch settings (replace with YOUR key)
TWITCH_KEY="YOUR_TWITCH_STREAM_KEY_HERE"
TWITCH_URL="rtmp://live.twitch.tv/app/${TWITCH_KEY}"
# Capture settings – adjust to your setup
# Example: capture from X11 :0.0 screen region 1280x720 at 30 FPS
DISPLAY=":0.0"
RESOLUTION="1280x720"
FRAMERATE="30"
# Audio source (PulseAudio example)
AUDIO_SOURCE="default"
if [ -f "${PIDFILE}" ]; then
pid="$(cat "${PIDFILE}" 2>/dev/null || echo "")"
if [ -n "${pid}" ] && kill -0 "${pid}" 2>/dev/null; then
echo "Stream already running (pid ${pid})"
exit 0
fi
fi
echo "Starting Twitch stream ..."
{
echo "===== $(date) – starting ffmpeg ====="
ffmpeg \
-f x11grab -video_size "${RESOLUTION}" -framerate "${FRAMERATE}" -i "${DISPLAY}" \
-f pulse -i "${AUDIO_SOURCE}" \
-c:v libx264 -preset veryfast -b:v 3500k -maxrate 3500k -bufsize 7000k \
-pix_fmt yuv420p -g 60 \
-c:a aac -b:a 160k -ar 44100 \
-f flv "${TWITCH_URL}"
echo "===== $(date) – ffmpeg stopped ====="
} >>"${LOGFILE}" 2>&1 &
echo $! > "${PIDFILE}"
echo "Stream started (pid $(cat "${PIDFILE}"))"
Make it executable:
chmod +x /home/tvbot/tw.sh
7. Step 4: Supervisor / Watchdog
tvbot_supervisor.sh
Create /home/tvbot/etlegacy/tvbot_supervisor.sh with this content:
#!/bin/bash
# tvbot_supervisor.sh – All-in-one controller + watchdog for ETc|TV (etl.sh) and Twitch stream (tw.sh)
#
# Run as user: tvbot (NOT root)
#
# Commands:
# ./tvbot_supervisor.sh start
# ./tvbot_supervisor.sh stop
# ./tvbot_supervisor.sh restart
# ./tvbot_supervisor.sh status
#
# Extra watchdog feature:
# - Watches etltv.log for "server disconnected" / lost connection / dropped
# and triggers a full restart (ETL + stream).
# - You can also extend the pattern to catch "Received signal 11" etc.
set -u
# ---- refuse root ----
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
echo "ERROR: Do not run this as root. Switch to user tvbot."
exit 1
fi
BASE_DIR="$HOME/etlegacy"
ETL_SCRIPT="$BASE_DIR/etl.sh"
TW_SCRIPT="$HOME/tw.sh"
ETL_PIDFILE="$BASE_DIR/etltv.pid"
TW_PIDFILE="$HOME/twitch_stream.pid"
SUP_PIDFILE="$BASE_DIR/tvbot_supervisor.pid"
LOGFILE="$BASE_DIR/tvbot_supervisor.log"
ETL_LAST_START_FILE="$BASE_DIR/.etl_last_start"
# ---- disconnect / crash log watching ----
ETL_LOGFILE="$BASE_DIR/etltv.log"
ETL_LOGPOS_FILE="$BASE_DIR/.etl_logpos"
ETL_DISCOOLDOWN_FILE="$BASE_DIR/.etl_last_disconnect"
DISCONNECT_COOLDOWN=90 # seconds between disconnect-triggered restarts
CHECK_INTERVAL=10 # seconds for watchdog loop
START_WAIT=8 # seconds to wait for pidfiles on start
WARMUP_AFTER_ETL_START=20 # seconds to wait before starting stream after ETL start/restart
cd "$BASE_DIR" || { echo "ERROR: $BASE_DIR not found"; exit 1; }
log() {
echo "$(date '+%F %T') – $*" >> "$LOGFILE"
}
pid_running() {
local pid="$1"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
running_from_pidfile() {
local pidfile="$1"
if [ -f "$pidfile" ]; then
local pid
pid=$(cat "$pidfile" 2>/dev/null || true)
if pid_running "$pid"; then
return 0
fi
fi
return 1
}
wait_for_pidfile() {
local pidfile="$1"
local seconds="$2"
local i
for ((i=0; i "$ETL_LAST_START_FILE" 2>/dev/null || true
}
etl_warmup_wait() {
local now last diff remain
now=$(date +%s)
last=0
if [ -f "$ETL_LAST_START_FILE" ]; then
last=$(cat "$ETL_LAST_START_FILE" 2>/dev/null || echo 0)
fi
diff=$((now - last))
if [ "$diff" -lt "$WARMUP_AFTER_ETL_START" ]; then
remain=$((WARMUP_AFTER_ETL_START - diff))
log "warmup wait ${remain}s before stream start"
sleep "$remain"
fi
}
start_etl() {
if running_from_pidfile "$ETL_PIDFILE"; then
echo "ETL already running (pid $(cat "$ETL_PIDFILE"))."
return 0
fi
if [ ! -x "$ETL_SCRIPT" ]; then
echo "ERROR: $ETL_SCRIPT not found or not executable."
return 1
fi
log "starting ETL via etl.sh"
nohup setsid "$ETL_SCRIPT" >/dev/null 2>&1 &
if wait_for_pidfile "$ETL_PIDFILE" "$START_WAIT"; then
echo "ETL started (pid $(cat "$ETL_PIDFILE"))."
log "ETL started ok (pid $(cat "$ETL_PIDFILE"))"
record_etl_start
return 0
else
echo "ETL start FAILED. Check: $BASE_DIR/etltv.log"
log "ETL start failed"
return 1
fi
}
start_tw() {
if running_from_pidfile "$TW_PIDFILE"; then
echo "Stream already running (pid $(cat "$TW_PIDFILE"))."
return 0
fi
if [ ! -x "$TW_SCRIPT" ]; then
echo "ERROR: $TW_SCRIPT not found or not executable."
return 1
fi
etl_warmup_wait
log "starting Twitch stream via tw.sh"
"$TW_SCRIPT" >> "$HOME/twitch_stream.log" 2>&1
if wait_for_pidfile "$TW_PIDFILE" "$START_WAIT"; then
echo "Stream started (pid $(cat "$TW_PIDFILE"))."
log "Stream started ok (pid $(cat "$TW_PIDFILE"))"
return 0
else
echo "Stream start FAILED. Check: $HOME/twitch_stream.log"
log "Stream start failed"
return 1
fi
}
stop_tw() {
if running_from_pidfile "$TW_PIDFILE"; then
local pid
pid=$(cat "$TW_PIDFILE")
log "stopping ffmpeg (pid $pid)"
kill "$pid" 2>/dev/null || true
wait_for_exit "$pid" 8 || true
fi
rm -f "$TW_PIDFILE"
echo "Stream stopped."
}
stop_etl() {
if running_from_pidfile "$ETL_PIDFILE"; then
local pid
pid=$(cat "$ETL_PIDFILE")
log "stopping ETL (pid $pid)"
kill "$pid" 2>/dev/null || true
wait_for_exit "$pid" 8 || true
fi
rm -f "$ETL_PIDFILE"
echo "ETL stopped."
}
start_supervisor() {
if running_from_pidfile "$SUP_PIDFILE"; then
echo "Supervisor already running (pid $(cat "$SUP_PIDFILE"))."
return 0
fi
log "starting supervisor loop"
nohup setsid "$0" supervise >/dev/null 2>&1 &
echo $! > "$SUP_PIDFILE"
echo "Supervisor started (pid $(cat "$SUP_PIDFILE"))."
log "supervisor started (pid $(cat "$SUP_PIDFILE"))"
}
stop_supervisor() {
if running_from_pidfile "$SUP_PIDFILE"; then
local pid
pid=$(cat "$SUP_PIDFILE")
log "stopping supervisor (pid $pid)"
kill "$pid" 2>/dev/null || true
wait_for_exit "$pid" 5 || true
fi
rm -f "$SUP_PIDFILE"
echo "Supervisor stopped."
}
status_all() {
if running_from_pidfile "$ETL_PIDFILE"; then
echo "ETL: RUNNING (pid $(cat "$ETL_PIDFILE"))"
else
echo "ETL: STOPPED"
fi
if running_from_pidfile "$TW_PIDFILE"; then
echo "Stream: RUNNING (pid $(cat "$TW_PIDFILE"))"
else
echo "Stream: STOPPED"
fi
if running_from_pidfile "$SUP_PIDFILE"; then
echo "Supervisor: RUNNING (pid $(cat "$SUP_PIDFILE"))"
else
echo "Supervisor: STOPPED"
fi
}
# ---- Detect "server disconnected" or crash in ET log (new lines only) ----
etl_log_has_disconnect() {
[ -f "$ETL_LOGFILE" ] || return 1
local size lastpos newdata now lastdisc diff
size=$(stat -c%s "$ETL_LOGFILE" 2>/dev/null || echo 0)
lastpos=0
if [ -f "$ETL_LOGPOS_FILE" ]; then
lastpos=$(cat "$ETL_LOGPOS_FILE" 2>/dev/null || echo 0)
fi
# log rotated / truncated
if [ "$size" -lt "$lastpos" ]; then
lastpos=0
fi
# read only new bytes
if [ "$size" -gt "$lastpos" ]; then
newdata=$(tail -c +$((lastpos+1)) "$ETL_LOGFILE" 2>/dev/null || true)
else
newdata=""
fi
echo "$size" > "$ETL_LOGPOS_FILE" 2>/dev/null || true
# cooldown so 1 disconnect doesn't spam restarts
now=$(date +%s)
lastdisc=0
if [ -f "$ETL_DISCOOLDOWN_FILE" ]; then
lastdisc=$(cat "$ETL_DISCOOLDOWN_FILE" 2>/dev/null || echo 0)
fi
diff=$((now - lastdisc))
if [ "$diff" -lt "$DISCONNECT_COOLDOWN" ]; then
return 1
fi
# match common ET:L disconnect / crash messages (case-insensitive)
echo "$newdata" | grep -Eiq \
"server disconnected|Server Disconnected|CL_Disconnect|lost connection|Connection reset|was dropped|client disconnected|Disconnected from server|NET:.*disconnect|Received signal 11" \
|| return 1
echo "$now" > "$ETL_DISCOOLDOWN_FILE" 2>/dev/null || true
return 0
}
full_restart_due_disconnect() {
log "disconnect/crash detected in etltv.log -> full restart"
stop_tw >/dev/null 2>&1 || true
stop_etl >/dev/null 2>&1 || true
sleep 3
start_etl >/dev/null 2>&1 || log "ETL restart after disconnect failed"
start_tw >/dev/null 2>&1 || log "Stream restart after disconnect failed"
}
supervise_loop() {
log "supervise loop entered"
while true; do
# 1) log-based disconnect/crash detection
if etl_log_has_disconnect; then
full_restart_due_disconnect
sleep "$CHECK_INTERVAL"
continue
fi
# 2) ETL process check
if ! running_from_pidfile "$ETL_PIDFILE"; then
log "ETL not running -> restarting"
start_etl >/dev/null 2>&1 || log "ETL restart failed"
# after ETL restart, restart stream too (with warmup)
if running_from_pidfile "$TW_PIDFILE"; then
stop_tw >/dev/null 2>&1 || true
fi
start_tw >/dev/null 2>&1 || log "Stream restart after ETL failed"
fi
# 3) Stream process check
if ! running_from_pidfile "$TW_PIDFILE"; then
log "Stream not running -> restarting"
start_tw >/dev/null 2>&1 || log "Stream restart failed"
fi
sleep "$CHECK_INTERVAL"
done
}
case "${1:-}" in
start)
start_etl
start_tw
start_supervisor
;;
stop)
stop_supervisor
stop_tw
stop_etl
;;
restart)
stop_supervisor
stop_tw
stop_etl
sleep 3
start_etl
start_tw
start_supervisor
;;
status)
status_all
;;
supervise)
supervise_loop
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
8. Step 5: Start, stop & restart
Daily usage
Run all commands as user tvbot:
cd /home/tvbot/etlegacy
# start ETL + stream + supervisor
./tvbot_supervisor.sh start
# show status
./tvbot_supervisor.sh status
# restart everything
./tvbot_supervisor.sh restart
# stop everything
./tvbot_supervisor.sh stop
Optional: You can add a cronjob for a clean restart once per day (for example
at 05:52):
crontab -e (as tvbot) and add:52 5 * * * /home/tvbot/etlegacy/tvbot_supervisor.sh restart >/dev/null 2>&1
9. Troubleshooting
Common problems
9.1 ETL stuck on "LOADING..."
- Check
/home/tvbot/etlegacy/etltv.logfor the last lines. - Ensure your config does not contain an incomplete
execline (everything after a//comment is ignored). - Make sure the TV-Bot profile uses sane video settings and a valid resolution.
9.2 Multiple TV clients starting
- Check that
etl.shwrites the PID toetltv.pidand does not start twice. - Make sure you did not copy
etl.shelsewhere and call both by accident.
9.3 Stream is running, but Twitch shows "offline"
- Confirm the TWITCH_KEY is correct and not revoked.
- Check
twitch_stream.logfor ffmpeg errors (codec not found, network issues, etc.). - Make sure your firewall allows outgoing RTMP to Twitch.
9.4 Supervisor spams restarts
- Check
tvbot_supervisor.logto see why it restarts. - Inspect patterns in
etl_log_has_disconnect()– if your ETL log contains similar text during normal operation, reduce the regex or increaseDISCONNECT_COOLDOWN.