Electroshock controller

This project aims to improve human motivation in diverse situations, starting with the first-person shooter game OpenArena (based on Quake III).

Status du project
Date de début 2015-04
Status prototype working, permanent hardware being designed
Initiateur Erik Rossen

This project was started as one of the exhibits of PTL's stand at LemanMake 2015 and again at LemanMake 2018.

It is largely inspired by the 2001 Tekken Torture Tournament and the interactive sculptures of Rick Gibson.

Since it's inception, the unrelated IndieGoGo project "The Shock Clock" has started.

  • Arduino Uno
  • Arduino mini-breadboard shield
  • Home-made electroshock bracelets made by cannibalising gag pens
  • 3 Dell laptops running Debian. The laptops are named skinner, pavlov, and nash.
  • patched versions of OpenArena, the open-source version of Quake3
  • client scripts written in shell and perl to send player health information to a central health server
  • a server script that listens for incoming player health information and sends that information to a shock controler
  • Arduino shock controler firmware

This photo shows the electroshock controler. It is based upon an Arduino Uno microcontroler attached via a USB cable to a laptop computer. An external 12VDC power supply feeds 3 drive transistors, one for each player's bracelet.

In the foreground is the first-generation version of the bracelet consisting of a simple plastic wristband with two steel bolts fixed in it so as to touch the player's skin.

The ends of the bolts are connected to the two terminals of a small shock circuit recuperated from a cheap gag pen.

12VDC is supplied from the output of the drive transistors to the shock circuit over a 2-meter twisted pair of wires.

The shock delivered by this circuit is about 9000 volts peak-to-peak at about 500Hz.

Debian's version of OpenArena, 0.8.8-9, is patched in order to log the player's health to the end of a file, /home/ptl/.openarena/baseoa/Health.dat, every time it is modified during game play, i.e. during power-ups or whenever the player gets hit. Any other game that uses this electroshock system must be similarly patched to export some notion of “health”.

The patch is named openarena-0.8.8/debian/patches/health-logging.patch:

Description: add health logging
 Added a small patch to log any health changes to the player.
Author: Erik Rossen <>

--- openarena-0.8.8.orig/code/cgame/cg_playerstate.c
+++ openarena-0.8.8/code/cgame/cg_playerstate.c
@@ -289,6 +289,21 @@ static void pushReward(sfxHandle_t sfx,
+fileHandle_t healthLogf;
+static void healthLog (int clientNum, int health) {
+	char tempString[1024];
+	if (!healthLogf) {
+		trap_FS_FOpenFile( "Health.dat", &healthLogf, FS_APPEND_SYNC );
+	}
+	Q_snprintf(tempString, 512, "%i %i\n", clientNum, health );
+	trap_FS_Write(tempString, strlen(tempString), healthLogf );
+	//FS_ForceFlush( healthLogf );
@@ -330,6 +345,12 @@ void CG_CheckLocalSounds( playerState_t
+	// ERIK: any health changes should be logged.  Also, isn't
+	// CG_CheckLocalSounds() a wierd subroutine to be calling from?
+	if ( ps->stats[STAT_HEALTH] != ops->stats[STAT_HEALTH] ) {
+		//CG_Printf( "HEALTH: client\t%i\thealth\t%i\n", ps->clientNum, ps->stats[STAT_HEALTH] );
+		healthLog( ps->clientNum, ps->stats[STAT_HEALTH] );
+	}
 	// if we are going into the intermission, don't start any voices
 	if ( cg.intermissionStarted ) {

The health-sender program is a simple wrapper for tailing the health file and feeding it into the health-client program.

This is health-sender:

killall health-client inotail
( inotail -f /home/ptl/.openarena/baseoa/Health.dat | /usr/local/bin/health-client ) &

The health-client program is reponsible for sending a player's health information over the network to a central health server that tracks all player's health scores and sends that information to the shock controler.

This is health-client:

#!/usr/bin/perl -w
# udpmsg - send a message to the udpquotd server

use IO::Socket;
use strict;

my($sock, $msg_in, $port, $ipaddr, $hishost, 

$MAXLEN  = 1024;
$PORTNO  = 8888;

$sock = IO::Socket::INET->new(Proto     => 'udp',
                              PeerPort  => $PORTNO,
                              PeerAddr  => 'skinner')
    or die "Creating socket: $!\n";

my $hostname = `hostname`;
chomp $hostname;

while ($msg_in = <>) { 
	my ($clientNumber,  $health) = split(" ", $msg_in);
	my $msg_out = "$hostname $health\n";
	print "$msg_out";
	# better not to have the program die if health-server is not yet open and/or unreachable
	# $sock->send($msg_out) or die "send: $!";

health-sender is launched by a short start-up script usually activated by clicking a desktop icon. This startup script is called openarena-skinner. The script is responsible for resetting the openarena configuration (in case a previous player changed it), starting the health sender, and starting the OpenArena graphical interface, connected to a central OpenArena instance where everyone plays. In this case, the central OpenArena is also on skinner.

This is openarena-skinner:

#!/bin/bash -x 
health-sender &
openarena +connect skinner &

This is reset-openarena-cfg:

# reset-openarena-cfg - reset local configuration to backed-up version



The health-server runs on the computer to which the shock controler is connected. In honor of the work of B.F. Skinner in operant conditioning, I chose to use the laptop named “skinner” as the computer responsible for commanding the shock controler.

This is health-server:

#!/usr/bin/perl -w
# udpqotd - UDP message server
use strict;
use IO::Socket;
use List::Util qw[min max];
use IO::Handle;
use Time::HiRes qw(gettimeofday);

my($sock, $newmsg, $hishost, $MAXLEN, $PORTNO);
my %h;
my %channel = ( "nash" => 1 , "pavlov" => 2 , "skinner" => 3 );

open DEBUG, ">>/tmp/fightclub-debug.txt";
# NOTE: it seems that autoflush is essential

$MAXLEN = 1024;
$PORTNO = 8888;
$sock = IO::Socket::INET->new(
           LocalPort => $PORTNO, 
       Proto => 'udp') or die "socket: $@";
print DEBUG "Awaiting UDP messages on port $PORTNO\n";
if ( -e "/dev/ttyACM0" ) {
	open ARDUINO, ">>/dev/ttyACM0";
if ( -e "/dev/ttyACM1" ) {
	open ARDUINO, ">>/dev/ttyACM1";
# NOTE: it seems that autoflush is essential

while ($sock->recv($newmsg, $MAXLEN)) {
    #my($port, $ipaddr) = sockaddr_in($sock->peername);
    ($clientName,$health)=split(" ",$newmsg);
    my $hit = max(0,min(max($h{$clientName},0),100) - min(max($health,0),100));
    $h{$clientName} = $health;
    my $c = $channel{$clientName};
    my ($seconds, $microseconds) = gettimeofday;
    print DEBUG "CLIENT:$clientName\tCHANNEL:$c\tHEALTH:$health\tHIT:$hit\tTIME:$seconds.$microseconds\n";
    print ARDUINO "$c $hit\n";
die "recv: $!";

The health-server is indirectly launched (via the “at” service) when the udev device-handling system on the on the computer detects the presence of the Arduino shock controler on the USB bus. When the Arduino is removed, the health-server is killed automatically.

This is the udev configuration /etc/udev/rules.d/99-fightclub.rules:

# for fightclub Arduino Uno controler
SUBSYSTEM=="usb", ACTION=="add", ATTRS{idVendor}=="2341", ATTRS{idProduct}=="0001", MODE="0666", RUN="/usr/local/bin/launch-health-server"
SUBSYSTEM=="usb", ACTION=="remove", ATTRS{ID_SERIAL_SHORT}=="6493234363835140D1D0", RUN="/usr/bin/killall health-server"
  Shock Controler
Receive damage reports on serial port and activate the shock channel

The commands are of the format "CHANNEL_NUMBER DAMAGE\n" where CHANNEL_NUMBER
is 1, 2, or 3 and DAMAGE is 1 to 100 points.  This is directly proportionals to
a shock duration in milliseconds.

TODO: One day, make shock voltage proportional to damage.

NOTE: It is assumed that the shock controller is attached to a trusted
computer, thus there is little or no input checking.
Created 21 June 2015
by Erik Rossen

String inputString = "";         // a string to hold incoming data
boolean stringComplete = false;  // whether the string is complete
String chanString = "";
String damageString = "";
String chanString1Time = "";
String chanString2Time = "";
String chanString3Time = "";
int chan1Time, chan2Time, chan3Time;
int chan;
int damage;

// define pins for each channel
int chan1p = 9;
int chan2p = 10;
int chan3p = 11;

void setup() {
  // initialize serial:
  // reserve 200 bytes for the inputString:
  chan1Time = 0;
  chan2Time = 0;
  chan3Time = 0;
  pinMode(chan1p, OUTPUT);
  digitalWrite(chan1p, LOW);
  pinMode(chan2p, OUTPUT);
  digitalWrite(chan2p, LOW);
  pinMode(chan3p, OUTPUT);
  digitalWrite(chan3p, LOW);

void loop() {
  if (stringComplete) {
    chanString = inputString.substring(0,1);
    damageString = inputString.substring(2);
    inputString = "";
    stringComplete = false;
    chan = chanString.toInt();
    damage = damageString.toInt();
    //Serial.println("CHANNEL: " + chanString + "  DAMAGE: " + damageString);

    if ( chan == 1 ) {
      chan1Time = min(chan1Time + damage, 100);
    if ( chan == 2 ) {
      chan2Time = min(chan2Time + damage, 100);
    if ( chan == 3 ) {
      chan3Time = min(chan3Time + damage, 100);

  if ( chan1Time > 0 ) {
    // chanString1Time = chanString1Time + chan1Time; Serial.println(chanString1Time); chanString1Time = "";
    digitalWrite(chan1p, HIGH);
  } else {
    digitalWrite(chan1p, LOW);
  if ( chan2Time > 0 ) {
    // chanString2Time = chanString2Time + chan2Time; Serial.println(chanString2Time); chanString2Time = "";
    digitalWrite(chan2p, HIGH);
  } else {
    digitalWrite(chan2p, LOW);
  if ( chan3Time > 0 ) {
    // chanString3Time = chanString3Time + chan3Time; Serial.println(chanString3Time); chanString3Time = "";
    digitalWrite(chan3p, HIGH);
  } else {
    digitalWrite(chan3p, LOW);

  delay(10); // 1=1ms loop
  chan1Time = max(chan1Time - 1,0);
  chan2Time = max(chan2Time - 1,0);
  chan3Time = max(chan3Time - 1,0);

  SerialEvent occurs whenever a new data comes in the
 hardware serial RX.  This routine is run between each
 time loop() runs, so using delay inside loop can delay
 response.  Multiple bytes of data may be available.
void serialEvent() {
  while (Serial.available()) {
    // get the new byte:
    char inChar = (char); 
    // add it to the inputString:
    inputString += inChar;
    // if the incoming character is a newline, set a flag
    // so the main loop can do something about it:
    if (inChar == '\n') {
      stringComplete = true;
  • projects/electronics/electroshock.txt
  • Dernière modification: 2018/09/27 21:57
  • de rossen