diff --git a/Dockerfile b/Dockerfile
index a7910350b244090bac8ee7b1f5c02831abf5712c..7df6f1d3252c56db87d574f27916e3cb0b748e0d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,11 +7,11 @@ RUN wget -O BombSquad_Server_Linux_64bit_$BS_VERSION.tar.gz $BS_MIRROR/BombSquad
   && tar xzf BombSquad_Server_Linux_64bit_$BS_VERSION.tar.gz \
   && rm BombSquad_Server_Linux_64bit_$BS_VERSION.tar.gz
 
-COPY ./config.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/config.py
-COPY ./bsTeamGame.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/data/scripts/bsTeamGame.py
-COPY ./bsGame.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/data/scripts/bsGame.py
-COPY ./bsBomb.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/data/scripts/bsBomb.py
-COPY ./bsSpaz.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/data/scripts/bsSpaz.py
+COPY ./scripts/config.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/config.py
+COPY ./scripts/bsTeamGame.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/data/scripts/bsTeamGame.py
+COPY ./scripts/bsGame.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/data/scripts/bsGame.py
+COPY ./scripts/bsBomb.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/data/scripts/bsBomb.py
+COPY ./scripts/bsSpaz.py /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/data/scripts/bsSpaz.py
 
 CMD /bombsquad/BombSquad_Server_Linux_64bit_$BS_VERSION/bombsquad_server
 EXPOSE 43212/udp
diff --git a/scripts/bsBomb.py b/scripts/bsBomb.py
new file mode 100644
index 0000000000000000000000000000000000000000..e311846aaa5270771900afc74887ca55ce03c08c
--- /dev/null
+++ b/scripts/bsBomb.py
@@ -0,0 +1,854 @@
+import bs
+import bsUtils
+from bsVector import Vector
+import random
+import weakref
+
+
+class BombFactory(object):
+    """
+    category: Game Flow Classes
+
+    Wraps up media and other resources used by bs.Bombs
+    A single instance of this is shared between all bombs
+    and can be retrieved via bs.Bomb.getFactory().
+
+    Attributes:
+
+       bombModel
+          The bs.Model of a standard or ice bomb.
+
+       stickyBombModel
+          The bs.Model of a sticky-bomb.
+
+       impactBombModel
+          The bs.Model of an impact-bomb.
+
+       landMinModel
+          The bs.Model of a land-mine.
+
+       tntModel
+          The bs.Model of a tnt box.
+
+       regularTex
+          The bs.Texture for regular bombs.
+
+       iceTex
+          The bs.Texture for ice bombs.
+
+       stickyTex
+          The bs.Texture for sticky bombs.
+
+       impactTex
+          The bs.Texture for impact bombs.
+
+       impactLitTex
+          The bs.Texture for impact bombs with lights lit.
+
+       landMineTex
+          The bs.Texture for land-mines.
+
+       landMineLitTex
+          The bs.Texture for land-mines with the light lit.
+
+       tntTex
+          The bs.Texture for tnt boxes.
+
+       hissSound
+          The bs.Sound for the hiss sound an ice bomb makes.
+
+       debrisFallSound
+          The bs.Sound for random falling debris after an explosion.
+
+       woodDebrisFallSound
+          A bs.Sound for random wood debris falling after an explosion.
+
+       explodeSounds
+          A tuple of bs.Sounds for explosions.
+
+       freezeSound
+          A bs.Sound of an ice bomb freezing something.
+
+       fuseSound
+          A bs.Sound of a burning fuse.
+
+       activateSound
+          A bs.Sound for an activating impact bomb.
+
+       warnSound
+          A bs.Sound for an impact bomb about to explode due to time-out.
+
+       bombMaterial
+          A bs.Material applied to all bombs.
+
+       normalSoundMaterial
+          A bs.Material that generates standard bomb noises on impacts, etc.
+
+       stickyMaterial
+          A bs.Material that makes 'splat' sounds and makes collisions softer.
+
+       landMineNoExplodeMaterial
+          A bs.Material that keeps land-mines from blowing up.
+          Applied to land-mines when they are created to allow land-mines to
+          touch without exploding.
+
+       landMineBlastMaterial
+          A bs.Material applied to activated land-mines that causes them to
+          explode on impact.
+
+       impactBlastMaterial
+          A bs.Material applied to activated impact-bombs that causes them to
+          explode on impact.
+
+       blastMaterial
+          A bs.Material applied to bomb blast geometry which triggers impact
+          events with what it touches.
+
+       dinkSounds
+          A tuple of bs.Sounds for when bombs hit the ground.
+
+       stickyImpactSound
+          The bs.Sound for a squish made by a sticky bomb hitting something.
+
+       rollSound
+          bs.Sound for a rolling bomb.
+    """
+
+    def getRandomExplodeSound(self):
+        'Return a random explosion bs.Sound from the factory.'
+        return self.explodeSounds[random.randrange(len(self.explodeSounds))]
+
+    def __init__(self):
+        """
+        Instantiate a BombFactory.
+        You shouldn't need to do this; call bs.Bomb.getFactory() to get a
+        shared instance.
+        """
+
+        self.bombModel = bs.getModel('bomb')
+        self.stickyBombModel = bs.getModel('bombSticky')
+        self.impactBombModel = bs.getModel('impactBomb')
+        self.landMineModel = bs.getModel('landMine')
+        self.tntModel = bs.getModel('tnt')
+
+        self.regularTex = bs.getTexture('bombColor')
+        self.iceTex = bs.getTexture('bombColorIce')
+        self.stickyTex = bs.getTexture('bombStickyColor')
+        self.impactTex = bs.getTexture('impactBombColor')
+        self.impactLitTex = bs.getTexture('impactBombColorLit')
+        self.landMineTex = bs.getTexture('landMine')
+        self.landMineLitTex = bs.getTexture('landMineLit')
+        self.tntTex = bs.getTexture('tnt')
+
+        self.hissSound = bs.getSound('hiss')
+        self.debrisFallSound = bs.getSound('debrisFall')
+        self.woodDebrisFallSound = bs.getSound('woodDebrisFall')
+
+        self.explodeSounds = (bs.getSound('explosion01'),
+                              bs.getSound('explosion02'),
+                              bs.getSound('explosion03'),
+                              bs.getSound('explosion04'),
+                              bs.getSound('explosion05'))
+
+        self.freezeSound = bs.getSound('freeze')
+        self.fuseSound = bs.getSound('fuse01')
+        self.activateSound = bs.getSound('activateBeep')
+        self.warnSound = bs.getSound('warnBeep')
+
+        # set up our material so new bombs dont collide with objects
+        # that they are initially overlapping
+        self.bombMaterial = bs.Material()
+        self.normalSoundMaterial = bs.Material()
+        self.stickyMaterial = bs.Material()
+
+        self.bombMaterial.addActions(
+            conditions=((('weAreYoungerThan',100),
+                         'or',('theyAreYoungerThan',100)),
+                        'and',('theyHaveMaterial',
+                               bs.getSharedObject('objectMaterial'))),
+            actions=(('modifyNodeCollision','collide',False)))
+
+        # we want pickup materials to always hit us even if we're currently not
+        # colliding with their node (generally due to the above rule)
+        self.bombMaterial.addActions(
+            conditions=('theyHaveMaterial',
+                        bs.getSharedObject('pickupMaterial')),
+            actions=(('modifyPartCollision','useNodeCollide', False)))
+        
+        self.bombMaterial.addActions(actions=('modifyPartCollision',
+                                              'friction', 0.3))
+
+        self.landMineNoExplodeMaterial = bs.Material()
+        self.landMineBlastMaterial = bs.Material()
+        self.landMineBlastMaterial.addActions(
+            conditions=(
+                ('weAreOlderThan',200),
+                 'and', ('theyAreOlderThan',200),
+                 'and', ('evalColliding',),
+                 'and', (('theyDontHaveMaterial',
+                          self.landMineNoExplodeMaterial),
+                         'and', (('theyHaveMaterial',
+                                  bs.getSharedObject('objectMaterial')),
+                                 'or',('theyHaveMaterial',
+                                       bs.getSharedObject('playerMaterial'))))),
+            actions=(('message', 'ourNode', 'atConnect', ImpactMessage())))
+        
+        self.impactBlastMaterial = bs.Material()
+        self.impactBlastMaterial.addActions(
+            conditions=(('weAreOlderThan', 200),
+                        'and', ('theyAreOlderThan',200),
+                        'and', ('evalColliding',),
+                        'and', (('theyHaveMaterial',
+                                 bs.getSharedObject('footingMaterial')),
+                               'or',('theyHaveMaterial',
+                                     bs.getSharedObject('objectMaterial')))),
+            actions=(('message','ourNode','atConnect',ImpactMessage())))
+
+        self.blastMaterial = bs.Material()
+        self.blastMaterial.addActions(
+            conditions=(('theyHaveMaterial',
+                         bs.getSharedObject('objectMaterial'))),
+            actions=(('modifyPartCollision','collide',True),
+                     ('modifyPartCollision','physical',False),
+                     ('message','ourNode','atConnect',ExplodeHitMessage())))
+
+        self.dinkSounds = (bs.getSound('bombDrop01'),
+                           bs.getSound('bombDrop02'))
+        self.stickyImpactSound = bs.getSound('stickyImpact')
+        self.rollSound = bs.getSound('bombRoll01')
+
+        # collision sounds
+        self.normalSoundMaterial.addActions(
+            conditions=('theyHaveMaterial',
+                        bs.getSharedObject('footingMaterial')),
+            actions=(('impactSound',self.dinkSounds,2,0.8),
+                     ('rollSound',self.rollSound,3,6)))
+
+        self.stickyMaterial.addActions(
+            actions=(('modifyPartCollision','stiffness',0.1),
+                     ('modifyPartCollision','damping',1.0)))
+
+        self.stickyMaterial.addActions(
+            conditions=(('theyHaveMaterial',
+                         bs.getSharedObject('playerMaterial')),
+                        'or', ('theyHaveMaterial',
+                               bs.getSharedObject('footingMaterial'))),
+            actions=(('message','ourNode','atConnect',SplatMessage())))
+
+class SplatMessage(object):
+    pass
+
+class ExplodeMessage(object):
+    pass
+
+class ImpactMessage(object):
+    """ impact bomb touched something """
+    pass
+
+class ArmMessage(object):
+    pass
+
+class WarnMessage(object):
+    pass
+
+class ExplodeHitMessage(object):
+    "Message saying an object was hit"
+    def __init__(self):
+        pass
+
+class Blast(bs.Actor):
+    """
+    category: Game Flow Classes
+
+    An explosion, as generated by a bs.Bomb.
+    """
+    def __init__(self, position=(0,1,0), velocity=(0,0,0), blastRadius=2.0,
+                 blastType="normal", sourcePlayer=None, hitType='explosion',
+                 hitSubType='normal'):
+        """
+        Instantiate with given values.
+        """
+        bs.Actor.__init__(self)
+        
+        factory = Bomb.getFactory()
+
+        self.blastType = blastType
+        self.sourcePlayer = sourcePlayer
+
+        self.hitType = hitType;
+        self.hitSubType = hitSubType;
+
+        # blast radius
+        self.radius = blastRadius
+
+        # set our position a bit lower so we throw more things upward
+        self.node = bs.newNode('region', delegate=self, attrs={
+            'position':(position[0], position[1]-0.1, position[2]),
+            'scale':(self.radius,self.radius,self.radius),
+            'type':'sphere',
+            'materials':(factory.blastMaterial,
+                         bs.getSharedObject('attackMaterial'))})
+
+        bs.gameTimer(50, self.node.delete)
+
+        # throw in an explosion and flash
+        explosion = bs.newNode("explosion", attrs={
+            'position':position,
+            'velocity':(velocity[0],max(-1.0,velocity[1]),velocity[2]),
+            'radius':self.radius,
+            'big':(self.blastType == 'tnt')})
+        if self.blastType == "ice":
+            explosion.color = (0, 0.05, 0.4)
+
+        bs.gameTimer(1000,explosion.delete)
+
+        if self.blastType != 'ice':
+            bs.emitBGDynamics(position=position, velocity=velocity,
+                              count=int(1.0+random.random()*4),
+                              emitType='tendrils',tendrilType='thinSmoke')
+        bs.emitBGDynamics(
+            position=position, velocity=velocity,
+            count=int(4.0+random.random()*4), emitType='tendrils',
+            tendrilType='ice' if self.blastType == 'ice' else 'smoke')
+        bs.emitBGDynamics(
+            position=position, emitType='distortion',
+            spread=1.0 if self.blastType == 'tnt' else 2.0)
+        
+        # and emit some shrapnel..
+        if self.blastType == 'ice':
+            def _doEmit():
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=30, spread=2.0, scale=0.4,
+                                  chunkType='ice', emitType='stickers');
+            bs.gameTimer(50, _doEmit) # looks better if we delay a bit
+
+        elif self.blastType == 'sticky':
+            def _doEmit():
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=int(4.0+random.random()*8),
+                                  spread=0.7,chunkType='slime');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=int(4.0+random.random()*8), scale=0.5,
+                                  spread=0.7,chunkType='slime');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=15, scale=0.6, chunkType='slime',
+                                  emitType='stickers');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=20, scale=0.7, chunkType='spark',
+                                  emitType='stickers');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=int(6.0+random.random()*12),
+                                  scale=0.8, spread=1.5,chunkType='spark');
+            bs.gameTimer(50,_doEmit) # looks better if we delay a bit
+
+        elif self.blastType == 'impact': # regular bomb shrapnel
+            def _doEmit():
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=int(4.0+random.random()*8), scale=0.8,
+                                  chunkType='metal');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=int(4.0+random.random()*8), scale=0.4,
+                                  chunkType='metal');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=20, scale=0.7, chunkType='spark',
+                                  emitType='stickers');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=int(8.0+random.random()*15), scale=0.8,
+                                  spread=1.5, chunkType='spark');
+            bs.gameTimer(50,_doEmit) # looks better if we delay a bit
+
+        else: # regular or land mine bomb shrapnel
+            def _doEmit():
+                if self.blastType != 'tnt':
+                    bs.emitBGDynamics(position=position, velocity=velocity,
+                                      count=int(4.0+random.random()*8),
+                                      chunkType='rock');
+                    bs.emitBGDynamics(position=position, velocity=velocity,
+                                      count=int(4.0+random.random()*8),
+                                      scale=0.5,chunkType='rock');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=30,
+                                  scale=1.0 if self.blastType=='tnt' else 0.7,
+                                  chunkType='spark', emitType='stickers');
+                bs.emitBGDynamics(position=position, velocity=velocity,
+                                  count=int(18.0+random.random()*20),
+                                  scale=1.0 if self.blastType == 'tnt' else 0.8,
+                                  spread=1.5, chunkType='spark');
+
+                # tnt throws splintery chunks
+                if self.blastType == 'tnt':
+                    def _emitSplinters():
+                        bs.emitBGDynamics(position=position, velocity=velocity,
+                                          count=int(20.0+random.random()*25),
+                                          scale=0.8, spread=1.0,
+                                          chunkType='splinter');
+                    bs.gameTimer(10,_emitSplinters)
+                
+                # every now and then do a sparky one
+                if self.blastType == 'tnt' or random.random() < 0.1:
+                    def _emitExtraSparks():
+                        bs.emitBGDynamics(position=position, velocity=velocity,
+                                          count=int(10.0+random.random()*20),
+                                          scale=0.8, spread=1.5,
+                                          chunkType='spark');
+                    bs.gameTimer(20,_emitExtraSparks)
+                        
+            bs.gameTimer(50,_doEmit) # looks better if we delay a bit
+
+        light = bs.newNode('light', attrs={
+            'position':position,
+            'volumeIntensityScale': 10.0,
+            'color': ((0.6, 0.6, 1.0) if self.blastType == 'ice'
+                      else (1, 0.3, 0.1))})
+
+        s = random.uniform(0.6,0.9)
+        scorchRadius = lightRadius = self.radius
+        if self.blastType == 'tnt':
+            lightRadius *= 1.4
+            scorchRadius *= 1.15
+            s *= 3.0
+
+        iScale = 1.6
+        bsUtils.animate(light,"intensity", {
+            0:2.0*iScale, int(s*20):0.1*iScale,
+            int(s*25):0.2*iScale, int(s*50):17.0*iScale, int(s*60):5.0*iScale,
+            int(s*80):4.0*iScale, int(s*200):0.6*iScale,
+            int(s*2000):0.00*iScale, int(s*3000):0.0})
+        bsUtils.animate(light,"radius", {
+            0:lightRadius*0.2, int(s*50):lightRadius*0.55,
+            int(s*100):lightRadius*0.3, int(s*300):lightRadius*0.15,
+            int(s*1000):lightRadius*0.05})
+        bs.gameTimer(int(s*3000),light.delete)
+
+        # make a scorch that fades over time
+        scorch = bs.newNode('scorch', attrs={
+            'position':position,
+            'size':scorchRadius*0.5,
+            'big':(self.blastType == 'tnt')})
+        if self.blastType == 'ice':
+            scorch.color = (1,1,1.5)
+
+        bsUtils.animate(scorch,"presence",{3000:1, 13000:0})
+        bs.gameTimer(13000,scorch.delete)
+
+        if self.blastType == 'ice':
+            bs.playSound(factory.hissSound,position=light.position)
+            
+        p = light.position
+        bs.playSound(factory.getRandomExplodeSound(),position=p)
+        bs.playSound(factory.debrisFallSound,position=p)
+
+        bs.shakeCamera(intensity=5.0 if self.blastType == 'tnt' else 1.0)
+
+        # tnt is more epic..
+        if self.blastType == 'tnt':
+            bs.playSound(factory.getRandomExplodeSound(),position=p)
+            def _extraBoom():
+                bs.playSound(factory.getRandomExplodeSound(),position=p)
+            bs.gameTimer(250,_extraBoom)
+            def _extraDebrisSound():
+                bs.playSound(factory.debrisFallSound,position=p)
+                bs.playSound(factory.woodDebrisFallSound,position=p)
+            bs.gameTimer(400,_extraDebrisSound)
+
+    def handleMessage(self, msg):
+        self._handleMessageSanityCheck()
+        
+        if isinstance(msg, bs.DieMessage):
+            self.node.delete()
+
+        elif isinstance(msg, ExplodeHitMessage):
+            node = bs.getCollisionInfo("opposingNode")
+            if node is not None and node.exists():
+                t = self.node.position
+
+                # new
+                mag = 2000.0
+                if self.blastType == 'ice': mag *= 0.5
+                elif self.blastType == 'landMine': mag *= 2.5
+                elif self.blastType == 'tnt': mag *= 2.0
+
+                node.handleMessage(bs.HitMessage(
+                    pos=t,
+                    velocity=(0,0,0),
+                    magnitude=mag,
+                    hitType=self.hitType,
+                    hitSubType=self.hitSubType,
+                    radius=self.radius,
+                    sourcePlayer=self.sourcePlayer))
+                if self.blastType == "ice":
+                    bs.playSound(Bomb.getFactory().freezeSound, 10, position=t)
+                    node.handleMessage(bs.FreezeMessage())
+
+        else:
+            bs.Actor.handleMessage(self, msg)
+
+class Bomb(bs.Actor):
+    """
+    category: Game Flow Classes
+    
+    A bomb and its variants such as land-mines and tnt-boxes.
+    """
+
+    def __init__(self, position=(0,1,0), velocity=(0,0,0), bombType='normal',
+                 blastRadius=2.0, sourcePlayer=None, owner=None):
+        """
+        Create a new Bomb.
+        
+        bombType can be 'ice','impact','landMine','normal','sticky', or 'tnt'.
+        Note that for impact or landMine bombs you have to call arm()
+        before they will go off.
+        """
+        bs.Actor.__init__(self)
+
+        factory = self.getFactory()
+
+        if not bombType in ('ice','impact','landMine','normal','sticky','tnt'):
+            raise Exception("invalid bomb type: " + bombType)
+        self.bombType = bombType
+
+        self._exploded = False
+
+        if self.bombType == 'sticky': self._lastStickySoundTime = 0
+
+        self.blastRadius = blastRadius
+        if self.bombType == 'ice': self.blastRadius *= 1.2
+        elif self.bombType == 'impact': self.blastRadius *= 0.7
+        elif self.bombType == 'landMine': self.blastRadius *= 0.7
+        elif self.bombType == 'tnt': self.blastRadius *= 1.45
+
+        self._explodeCallbacks = []
+        
+        # the player this came from
+        self.sourcePlayer = sourcePlayer
+
+        # by default our hit type/subtype is our own, but we pick up types of
+        # whoever sets us off so we know what caused a chain reaction
+        self.hitType = 'explosion'
+        self.hitSubType = self.bombType
+
+        # if no owner was provided, use an unconnected node ref
+        if owner is None: owner = bs.Node(None)
+
+        # the node this came from
+        self.owner = owner
+
+        # adding footing-materials to things can screw up jumping and flying
+        # since players carrying those things
+        # and thus touching footing objects will think they're on solid ground..
+        # perhaps we don't wanna add this even in the tnt case?..
+        if self.bombType == 'tnt':
+            materials = (factory.bombMaterial,
+                         bs.getSharedObject('footingMaterial'),
+                         bs.getSharedObject('objectMaterial'))
+        else:
+            materials = (factory.bombMaterial,
+                         bs.getSharedObject('objectMaterial'))
+            
+        if self.bombType == 'impact':
+            materials = materials + (factory.impactBlastMaterial,)
+        elif self.bombType == 'landMine':
+            materials = materials + (factory.landMineNoExplodeMaterial,)
+
+        if self.bombType == 'sticky':
+            materials = materials + (factory.stickyMaterial,)
+        else:
+            materials = materials + (factory.normalSoundMaterial,)
+
+        if self.bombType == 'landMine':
+            self.node = bs.newNode('prop', delegate=self, attrs={
+                'position':position,
+                'velocity':velocity,
+                'model':factory.landMineModel,
+                'lightModel':factory.landMineModel,
+                'body':'landMine',
+                'shadowSize':0.44,
+                'colorTexture':factory.landMineTex,
+                'reflection':'powerup',
+                'reflectionScale':[1.0],
+                'materials':materials})
+
+        elif self.bombType == 'tnt':
+            self.node = bs.newNode('prop', delegate=self, attrs={
+                'position':position,
+                'velocity':velocity,
+                'model':factory.tntModel,
+                'lightModel':factory.tntModel,
+                'body':'crate',
+                'shadowSize':0.5,
+                'colorTexture':factory.tntTex,
+                'reflection':'soft',
+                'reflectionScale':[0.23],
+                'materials':materials})
+            
+        elif self.bombType == 'impact':
+            fuseTime = 20000
+            self.node = bs.newNode('prop', delegate=self, attrs={
+                'position':position,
+                'velocity':velocity,
+                'body':'sphere',
+                'model':factory.impactBombModel,
+                'shadowSize':0.3,
+                'colorTexture':factory.impactTex,
+                'reflection':'powerup',
+                'reflectionScale':[1.5],
+                'materials':materials})
+            self.armTimer = bs.Timer(200, bs.WeakCall(self.handleMessage,
+                                                      ArmMessage()))
+            self.warnTimer = bs.Timer(fuseTime-1700,
+                                      bs.WeakCall(self.handleMessage,
+                                                  WarnMessage()))
+
+        else:
+            fuseTime = 3000
+            if self.bombType == 'sticky':
+                sticky = True
+                model = factory.stickyBombModel
+                rType = 'sharper'
+                rScale = 1.8
+            else:
+                sticky = False
+                model = factory.bombModel
+                rType = 'sharper'
+                rScale = 1.8
+            if self.bombType == 'ice': tex = factory.iceTex
+            elif self.bombType == 'sticky': tex = factory.stickyTex
+            else: tex = factory.regularTex
+            self.node = bs.newNode('bomb', delegate=self, attrs={
+                'position':position,
+                'velocity':velocity,
+                'model':model,
+                'shadowSize':0.3,
+                'colorTexture':tex,
+                'sticky':sticky,
+                'owner':owner,
+                'reflection':rType,
+                'reflectionScale':[rScale],
+                'materials':materials})
+
+            sound = bs.newNode('sound', owner=self.node, attrs={
+                'sound':factory.fuseSound,
+                'volume':0.25})
+            self.node.connectAttr('position', sound, 'position')
+            bsUtils.animate(self.node, 'fuseLength', {0:1.0, fuseTime:0.0})
+
+        # light the fuse!!!
+        if self.bombType not in ('landMine','tnt'):
+            bs.gameTimer(fuseTime,
+                         bs.WeakCall(self.handleMessage, ExplodeMessage()))
+
+        bsUtils.animate(self.node,"modelScale",{0:0, 200:1.3, 260:1})
+
+    def getSourcePlayer(self):
+        """
+        Returns a bs.Player representing the source of this bomb.
+        """
+        if self.sourcePlayer is None: return bs.Player(None) # empty player ref
+        return self.sourcePlayer
+        
+    @classmethod
+    def getFactory(cls):
+        """
+        Returns a shared bs.BombFactory object, creating it if necessary.
+        """
+        activity = bs.getActivity()
+        try: return activity._sharedBombFactory
+        except Exception:
+            f = activity._sharedBombFactory = BombFactory()
+            return f
+
+    def onFinalize(self):
+        bs.Actor.onFinalize(self)
+        # release callbacks/refs so we don't wind up with dependency loops..
+        self._explodeCallbacks = []
+        
+    def _handleDie(self,m):
+        self.node.delete()
+        
+    def _handleOOB(self, msg):
+        self.handleMessage(bs.DieMessage())
+
+    def _handleImpact(self,m):
+        node,body = bs.getCollisionInfo("opposingNode","opposingBody")
+        # if we're an impact bomb and we came from this node, don't explode...
+        # alternately if we're hitting another impact-bomb from the same source,
+        # don't explode...
+        try: nodeDelegate = node.getDelegate()
+        except Exception: nodeDelegate = None
+        if node is not None and node.exists():
+            if (self.bombType == 'impact' and
+                (node is self.owner
+                 or (isinstance(nodeDelegate, Bomb)
+                     and nodeDelegate.bombType == 'impact'
+                     and nodeDelegate.owner is self.owner))): return
+            else:
+                self.handleMessage(ExplodeMessage())
+
+    def _handleDropped(self,m):
+        if self.bombType == 'landMine':
+            self.armTimer = \
+                bs.Timer(1250, bs.WeakCall(self.handleMessage, ArmMessage()))
+
+        # once we've thrown a sticky bomb we can stick to it..
+        elif self.bombType == 'sticky':
+            def _safeSetAttr(node,attr,value):
+                if node.exists(): setattr(node,attr,value)
+            bs.gameTimer(
+                250, lambda: _safeSetAttr(self.node, 'stickToOwner', True))
+
+    def _handleSplat(self,m):
+        node = bs.getCollisionInfo("opposingNode")
+        if (node is not self.owner
+                and bs.getGameTime() - self._lastStickySoundTime > 1000):
+            self._lastStickySoundTime = bs.getGameTime()
+            bs.playSound(self.getFactory().stickyImpactSound, 2.0,
+                         position=self.node.position)
+
+    def addExplodeCallback(self,call):
+        """
+        Add a call to be run when the bomb has exploded.
+        The bomb and the new blast object are passed as arguments.
+        """
+        self._explodeCallbacks.append(call)
+        
+    def explode(self):
+        """
+        Blows up the bomb if it has not yet done so.
+        """
+        if self._exploded: return
+        self._exploded = True
+        activity = self.getActivity()
+        if activity is not None and self.node.exists():
+            blast = Blast(
+                position=self.node.position,
+                velocity=self.node.velocity,
+                blastRadius=self.blastRadius,
+                blastType=self.bombType,
+                sourcePlayer=self.sourcePlayer,
+                hitType=self.hitType,
+                hitSubType=self.hitSubType).autoRetain()
+            for c in self._explodeCallbacks: c(self,blast)
+            
+        # we blew up so we need to go away
+        bs.gameTimer(1, bs.WeakCall(self.handleMessage, bs.DieMessage()))
+
+    def _handleWarn(self, m):
+        if self.textureSequence.exists():
+            self.textureSequence.rate = 30
+            bs.playSound(self.getFactory().warnSound, 0.5,
+                         position=self.node.position)
+
+    def _addMaterial(self, material):
+        if not self.node.exists(): return
+        materials = self.node.materials
+        if not material in materials:
+            self.node.materials = materials + (material,)
+        
+    def arm(self):
+        """
+        Arms land-mines and impact-bombs so
+        that they will explode on impact.
+        """
+        if not self.node.exists(): return
+        factory = self.getFactory()
+        if self.bombType == 'landMine':
+            self.textureSequence = \
+                bs.newNode('textureSequence', owner=self.node, attrs={
+                    'rate':30,
+                    'inputTextures':(factory.landMineLitTex,
+                                     factory.landMineTex)})
+            bs.gameTimer(500,self.textureSequence.delete)
+            # we now make it explodable.
+            bs.gameTimer(250,bs.WeakCall(self._addMaterial,
+                                         factory.landMineBlastMaterial))
+        elif self.bombType == 'impact':
+            self.textureSequence = \
+                bs.newNode('textureSequence', owner=self.node, attrs={
+                    'rate':100,
+                    'inputTextures':(factory.impactLitTex,
+                                     factory.impactTex,
+                                     factory.impactTex)})
+            bs.gameTimer(250, bs.WeakCall(self._addMaterial,
+                                          factory.landMineBlastMaterial))
+        else:
+            raise Exception('arm() should only be called '
+                            'on land-mines or impact bombs')
+        self.textureSequence.connectAttr('outputTexture',
+                                         self.node, 'colorTexture')
+        bs.playSound(factory.activateSound, 0.5, position=self.node.position)
+        
+    def _handleHit(self, msg):
+        isPunch = (msg.srcNode.exists() and msg.srcNode.getNodeType() == 'spaz')
+
+        # normal bombs are triggered by non-punch impacts..
+        # impact-bombs by all impacts
+        if (not self._exploded and not isPunch
+            or self.bombType in ['impact', 'landMine']):
+            # also lets change the owner of the bomb to whoever is setting
+            # us off.. (this way points for big chain reactions go to the
+            # person causing them)
+            if msg.sourcePlayer not in [None]:
+                self.sourcePlayer = msg.sourcePlayer
+
+                # also inherit the hit type (if a landmine sets off by a bomb,
+                # the credit should go to the mine)
+                # the exception is TNT.  TNT always gets credit.
+                if self.bombType != 'tnt':
+                    self.hitType = msg.hitType
+                    self.hitSubType = msg.hitSubType
+
+            bs.gameTimer(100+int(random.random()*100),
+                         bs.WeakCall(self.handleMessage, ExplodeMessage()))
+        self.node.handleMessage(
+            "impulse", msg.pos[0], msg.pos[1], msg.pos[2],
+            msg.velocity[0], msg.velocity[1], msg.velocity[2],
+            msg.magnitude, msg.velocityMagnitude, msg.radius, 0,
+            msg.velocity[0], msg.velocity[1], msg.velocity[2])
+
+        if msg.srcNode.exists():
+            pass
+        
+    def handleMessage(self, msg):
+        if isinstance(msg, ExplodeMessage): self.explode()
+        elif isinstance(msg, ImpactMessage): self._handleImpact(msg)
+        elif isinstance(msg, bs.PickedUpMessage):
+            # change our source to whoever just picked us up *only* if its None
+            # this way we can get points for killing bots with their own bombs
+            # hmm would there be a downside to this?...
+            if self.sourcePlayer is not None:
+                self.sourcePlayer = msg.node.sourcePlayer
+        elif isinstance(msg, SplatMessage): self._handleSplat(msg)
+        elif isinstance(msg, bs.DroppedMessage): self._handleDropped(msg)
+        elif isinstance(msg, bs.HitMessage): self._handleHit(msg)
+        elif isinstance(msg, bs.DieMessage): self._handleDie(msg)
+        elif isinstance(msg, bs.OutOfBoundsMessage): self._handleOOB(msg)
+        elif isinstance(msg, ArmMessage): self.arm()
+        elif isinstance(msg, WarnMessage): self._handleWarn(msg)
+        else: bs.Actor.handleMessage(self, msg)
+
+class TNTSpawner(object):
+    """
+    category: Game Flow Classes
+
+    Regenerates TNT at a given point in space every now and then.
+    """
+    def __init__(self,position,respawnTime=30000):
+        """
+        Instantiate with a given position and respawnTime (in milliseconds).
+        """
+        self._position = position
+        self._tnt = None
+        self._update()
+        self._updateTimer = bs.Timer(1000,bs.WeakCall(self._update),repeat=True)
+        self._respawnTime = int(random.uniform(0.8,1.2)*respawnTime)
+        self._waitTime = 0
+        
+    def _update(self):
+        tntAlive = self._tnt is not None and self._tnt.node.exists()
+        if not tntAlive:
+            # respawn if its been long enough.. otherwise just increment our
+            # how-long-since-we-died value
+            if self._tnt is None or self._waitTime >= self._respawnTime:
+                self._tnt = Bomb(position=self._position,bombType='tnt')
+                self._waitTime = 0
+            else: self._waitTime += 1000
diff --git a/scripts/bsGame.py b/scripts/bsGame.py
new file mode 100644
index 0000000000000000000000000000000000000000..96edae04d094434ecf9c010a73159acb52fb39eb
--- /dev/null
+++ b/scripts/bsGame.py
@@ -0,0 +1,3040 @@
+import bs
+import bsInternal
+from bsVector import Vector
+import weakref
+import random
+import bsUtils
+import time
+
+
+class Team(object):
+    """
+    category: Game Flow Classes
+
+    A team of one or more bs.Players.
+    Note that a player *always* has a team;
+    in some cases, such as free-for-all bs.Sessions,
+    the teams consists of just one bs.Player each.
+
+    Attributes:
+
+       name
+          The team's name.
+
+       color
+          The team's color.
+
+       players
+          The list of bs.Players on the team.
+
+       gameData
+          A dict for use by the current bs.Activity
+          for storing data associated with this team.
+          This gets cleared for each new bs.Activity.
+
+       sessionData
+          A dict for use by the current bs.Session for
+          storing data associated with this team.
+          Unlike gameData, this perists for the duration
+          of the session.
+    """
+
+    def __init__(self, teamID=0, name='', color=(1, 1, 1)):
+        """
+        Instantiate a team. In most cases teams are provided to you
+        automatically by the bs.Session, so calling this shouldn't be necessary.
+        """
+        # we override __setattr__ to lock ourself down
+        # so we have to set attrs all funky-like
+        object.__setattr__(self, '_teamID', teamID)
+        object.__setattr__(self, 'name', name)
+        object.__setattr__(self, 'color', tuple(color))
+        object.__setattr__(self, 'players', [])
+        object.__setattr__(self, 'gameData', {})  # per-game user-data
+        object.__setattr__(self, 'sessionData', {})  # per-session user-data
+
+    def getID(self):
+        'Returns the numeric team ID.'
+        return object.__getattribute__(self, '_teamID')
+
+    def celebrate(self, duration=10000):
+        'Tells all players on the team to celebrate'
+        for player in self.players:
+            try:
+                player.actor.node.handleMessage('celebrate', 10000)
+            except Exception:
+                pass
+
+    def _reset(self):
+        self._resetGameData()
+        object.__setattr__(self, 'players', [])
+
+    def _resetGameData(self):
+        object.__setattr__(self, 'gameData', {})
+
+    def _resetSessionData(self):
+        object.__setattr__(self, 'sessionData', {})
+
+    def __setattr__(self, name, value):
+        raise Exception("can't set attrs on bs.Team objects")
+
+
+class OutOfBoundsMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object that it is out of bounds.
+    """
+    pass
+
+
+class DieMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object to die.
+    Most bs.Actors respond to this.
+
+    Attributes:
+
+       immediate
+          If this is set to True, the actor should disappear immediately.
+          This is for 'removing' stuff from the game moreso than 'killing' it.
+          If False, the actor should die a 'normal' death and can take its time
+          with lingering corpses, sound effects, etc.
+
+       how
+          The particular reason for death; 'fall', 'impact', 'leftGame', etc.
+          This can be examined for scoring or other purposes.
+
+    """
+
+    def __init__(self, immediate=False, how="generic"):
+        """
+        Instantiate with the given values.
+        """
+        self.immediate = immediate
+        self.how = how
+
+
+class StandMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object to position itself to be standing upright at the given
+    position. Used when teleporting players to home base, etc.
+
+    Attributes:
+
+       position
+          Where to stand.
+
+       angle
+          The angle to face (in degrees)
+    """
+
+    def __init__(self, position=(0, 0, 0), angle=0):
+        """
+        Instantiate with a given position and angle.
+        """
+        self.position = position
+        self.angle = angle
+
+
+class PickUpMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object that it has picked something up.
+
+    Attributes:
+
+       node
+          The bs.Node that is getting picked up.
+    """
+
+    def __init__(self, node):
+        'Instantiate with a given bs.Node.'
+        self.node = node
+
+
+class DropMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object that it has dropped whatever it was holding.
+    """
+    pass
+
+
+class PickedUpMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object that it has been picked up by something.
+
+    Attributes:
+
+       node
+          The bs.Node doing the picking up.
+    """
+
+    def __init__(self, node):
+        """
+        Instantiate with a given bs.Node.
+        """
+        self.node = node
+
+
+class DroppedMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object that it has been dropped.
+
+    Attributes:
+
+       node
+          The bs.Node doing the dropping.
+    """
+
+    def __init__(self, node):
+        """
+        Instantiate with a given bs.Node.
+        """
+        self.node = node
+
+
+class ShouldShatterMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object that it should shatter.
+    """
+    pass
+
+
+class ImpactDamageMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object that it has been jarred violently and
+    may want to be damaged.
+
+    Attributes:
+
+        intensity
+            The intensity of the impact.
+    """
+
+    def __init__(self, intensity):
+        """
+        Instantiate a messages with a given intensity value.
+        """
+        self.intensity = intensity
+
+
+class FreezeMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object to become frozen
+    (as in the effects of an ice bs.Bomb).
+    """
+    pass
+
+
+class ThawMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object that was frozen by a bs.FrozenMessage
+    to thaw out.
+    """
+    pass
+
+
+class HitMessage(object):
+    """
+    category: Message Classes
+
+    Tells an object it has been hit in some way.
+    This is used by punches, explosions, etc to convey
+    their effect to a target.
+    """
+
+    def __init__(
+            self, srcNode=None, pos=Vector(0, 0, 0),
+            velocity=Vector(0, 0, 0),
+            magnitude=1.0, velocityMagnitude=0.0, radius=1.0, sourcePlayer=None,
+            kickBack=1.0, flatDamage=None, hitType='generic',
+            forceDirection=None, hitSubType='default'):
+        """
+        Instantiate a message with various bits of information
+        on the type of hit that occurred.
+        """
+        # convert None to empty node-ref/player-ref
+        if srcNode is None:
+            srcNode = bs.Node(None)
+        if sourcePlayer is None:
+            sourcePlayer = bs.Player(None)
+
+        self.srcNode = srcNode
+        self.pos = pos
+        self.velocity = velocity
+        self.magnitude = magnitude
+        self.velocityMagnitude = velocityMagnitude
+        self.radius = radius
+        self.sourcePlayer = sourcePlayer
+        self.kickBack = kickBack
+        self.flatDamage = flatDamage
+        self.hitType = hitType
+        self.hitSubType = hitSubType
+        self.forceDirection = (forceDirection if forceDirection is not None
+                               else velocity)
+
+
+class Actor(object):
+    """
+    category: Game Flow Classes
+
+    Actors are high level organizational entities that generally manage one or
+    more bs.Nodes, bits of game media, etc, along with the associated logic to
+    wrangle them.
+
+    If bs.Nodes represent cells, think of bs.Actors as organs.
+    Some example actors include bs.Bomb, bs.Flag, and bs.Spaz.
+
+    One key feature of actors is that they generally 'die'
+    (killing off or transitioning out their nodes) when the last python
+    reference to them disappears, so you can use logic such as:
+
+    # create a flag in our activity
+    self.flag = bs.Flag(position=(0,10,0))
+
+    # later, destroy the flag..
+    # (provided nothing else is holding a reference to it)
+    self.flag = None
+
+    This is in contrast to the behavior of the more low level bs.Nodes,
+    which are always explicitly created and destroyed regardless of how many
+    python references to them exist.
+
+    Another key feature of bs.Actor is its handleMessage() method, which
+    takes a single arbitrary object as an argument. This provides a safe way
+    to communicate between bs.Actor, bs.Activity, bs.Session, and any other
+    object providing a handleMessage() method.  The most universally handled
+    message type for actors is the bs.DieMessage.
+
+    # another way to kill the flag from the example above:
+    # we can safely call this on any bombsquad type with a 'handleMessage'
+    # method. (though its not guaranteed to always have a meaningful effect)
+    self.flag.handleMessage(bs.DieMessage())
+    """
+
+    def __init__(self):
+        """
+        Instantiates an actor in the current bs.Activity.
+        """
+        # we technically shouldn't need to store a ref to
+        # the current activity; it should just come along in our context..
+        activity = bs.getActivity()
+        self._activity = weakref.ref(activity)
+
+        # update; we now *always* add a weak-ref...
+        activity._addActorWeakRef(self)
+
+    def __del__(self):
+        try:
+            # non-finalized actors send themselves a DieMessage when going down
+            if not self.isFinalized():
+                self.handleMessage(DieMessage())
+        except Exception:
+            bs.printException('exception in bs.Actor.__del__() for', self)
+
+    def handleMessage(self, msg):
+        """
+        General message handling; can be passed any message object.
+        """
+        pass
+
+    def _handleMessageSanityCheck(self):
+        if self.isFinalized():
+            bs.printError('handleMessage called on finalized actor', self)
+
+    def autoRetain(self):
+        """
+        Automatically keeps this bs.Actor in existence by storing a
+        reference to it with the bs.Activity it was created in. The reference
+        is released once the actor no longer exists (see bs.Actor.exists())
+        or when the Activity is finalized.  This can be a convenient alternative
+        to storing references explicitly just to keep a bs.Actor from dying.
+        For convenience, this method returns the bs.Actor it is called with,
+        enabling chained statements such as:  myFlag = bs.Flag().autoRetain()
+        """
+        activity = self._activity()
+        if activity is None:
+            raise Exception("actor's activity not found")
+        activity._retainActor(self)
+        return self
+
+    def onFinalize(self):
+        """
+        onFinalize is called for each remaining bs.Actor when its bs.Activity
+        is shutting down. Actors can use this opportunity to clear callbacks
+        or other references which have the potential of keeping the
+        bs.Activity alive inadvertantly.
+
+        Once an actor has been finalized (see bs.Actor.isFinalized()) it should
+        no longer perform any game-object manipulation (creating, modifying,
+        or deleting nodes, media, timers, etc.) Attempts to do so will
+        likely result in errors.
+        """
+        pass
+
+    def isFinalized(self):
+        """
+        Returns whether the actor has been finalized.
+        (see bs.Actor.onFinalize())
+        """
+        activity = self.getActivity(exceptionOnNone=False)
+        return True if activity is None else activity.isFinalized()
+
+    def exists(self):
+        """
+        Returns True if the actor is still visible or present in some way.
+        Note that a dying character should still return True here as long
+        as their corpse exists; this is about presence, not being 'alive'.
+
+        If this returns False, it is assumed the actor can be completely
+        deleted without affecting the game; this call is often used
+        when 'pruning' lists of actors.
+
+        The default implementation of this method returns 'node.exists()'
+        if the actor has a 'node' attr; otherwise True.
+        """
+
+        # as a sensible default, return the existance of self.node
+        node = getattr(self, 'node', None)
+        if node is not None:
+            return node.exists()
+        else:
+            return True
+
+    def isAlive(self):
+        """
+        Returns whether the Actor is 'alive'.
+        What this means is up to the actor.
+        It is not a requirement for actors to be
+        able to die; just that they report whether
+        they are alive or not.
+        """
+        return True
+
+    def getActivity(self, exceptionOnNone=True):
+        """
+        Return the bs.Activity this Actor is associated with.
+        If the activity no longer exists, returns None.
+        """
+        a = self._activity()
+        if a is None and exceptionOnNone:
+            raise Exception("Activity not found")
+        return a
+
+
+class NodeActor(Actor):
+    """
+    category: Game Flow Classes
+
+    A simple bs.Actor which wraps around a single bs.Node and kills
+    the node when told to die. This allows you to take advantage of
+    standard actor behavior such as dying when no longer referenced,
+    so you can do things like kill off a bunch of nodes simply by
+    clearing a python list, etc.
+
+    Attributes:
+
+       node
+          The wrapped node.
+    """
+
+    def __init__(self, node):
+        """
+        Instantiate with a given bs.Node.
+        """
+        Actor.__init__(self)
+        self.node = node
+
+    def handleMessage(self, msg):
+        if isinstance(msg, DieMessage):
+            self.node.delete()
+
+
+class Session(object):
+    """
+    category: Game Flow Classes
+
+    A Session is the highest level control structure in the game.
+    Types of sessions are bs.FreeForAllSession, bs.TeamsSession, and
+    bs.CoopSession.
+
+    A Session is responsible for wrangling and transitioning between various
+    bs.Activity instances such as mini-games and score-screens, and for
+    maintaining state between them (players, teams, score tallies, etc).
+
+    Attributes:
+
+       teams
+          All the bs.Teams in the Session. Most things should use the team
+          list in bs.Activity; not this.
+
+       players
+          All bs.Players in the Session. Most things should use the player
+          list in bs.Activity; not this.
+    """
+
+    def __init__(self, teamNames=['Good Guys'],
+                 teamColors=[(0.6, 0.2, 1.0)],
+                 useTeamColors=True,
+                 minPlayers=1,
+                 maxPlayers=8,
+                 allowMidActivityJoins=True):
+        """
+        Instantiate a session with the provided info about'
+        teams and max players.
+        """
+
+        import bsLobby
+        import bsScoreSet
+
+        # first thing, generate our link to our C layer equivalent..
+        self._sessionData = bsInternal._registerSession(self)
+
+        self._useTeams = (teamNames is not None)
+        self._useTeamColors = useTeamColors
+        self._inSetActivity = False
+
+        self._allowMidActivityJoins = allowMidActivityJoins
+
+        self.teams = []
+        self.players = []
+        self._nextTeamID = 0
+        self._activityRetained = None
+
+        # hacky way to create empty weak ref; must be a better way...
+        class EmptyObj:
+            pass
+        self._activityWeak = weakref.ref(EmptyObj())
+        if self._activityWeak() is not None:
+            raise Exception("error creating empty weak ref")
+
+        self._nextActivity = None
+        self._wantToEnd = False
+        self._ending = False
+        self._minPlayers = minPlayers
+        self._maxPlayers = maxPlayers
+
+        if self._useTeams:
+            for i in range(len(teamColors)):
+                team = bs.Team(
+                    teamID=self._nextTeamID,
+                    name=GameActivity.getTeamDisplayString(teamNames[i]),
+                    color=teamColors[i])
+                self.teams.append(team)
+                self._nextTeamID += 1
+
+                try:
+                    with bs.Context(self):
+                        self.onTeamJoin(team)
+                except Exception:
+                    bs.printException('exception in onTeamJoin for', self)
+
+        self._lobby = bsLobby.Lobby()
+        self.scoreSet = bsScoreSet.ScoreSet()
+
+        # instantiates our session globals node.. (so it can apply
+        # default settings)
+        bs.getSharedObject('globals')
+
+    def onPlayerRequest(self, player):
+        """
+        Called when a new bs.Player wants to join;
+        should return True or False to accept/reject.
+        """
+        # limit player counts based on pro purchase/etc *unless* we're in a
+        # stress test
+        if bsUtils._gStressTestResetTimer is None:
+
+            if len(self.players) >= self._maxPlayers:
+
+                # print a rejection message *only* to the client trying to join
+                # (prevents spamming everyone else in the game)
+                bs.playSound(bs.getSound('error'))
+                bs.screenMessage(
+                    bs.Lstr(
+                        resource='playerLimitReachedText',
+                        subs=[('${COUNT}', str(self._maxPlayers))]),
+                    color=(0.8, 0.0, 0.0),
+                    clients=[player.getInputDevice().getClientID()],
+                    transient=True)
+                return False
+
+        bs.playSound(bs.getSound('dripity'))
+        return True
+
+    def onPlayerLeave(self, player):
+        """
+        Called when a previously-accepted bs.Player leaves the session.
+        """
+        # remove them from the game rosters
+        if player in self.players:
+
+            bs.playSound(bs.getSound('playerLeft'))
+
+            # this will be None if the player is still in the chooser
+            team = player.getTeam()
+
+            activity = self._activityWeak()
+
+            # if he had no team, he's in the lobby
+            # if we have a current activity with a lobby, ask them to remove him
+            if team is None:
+                with bs.Context(self):
+                    try:
+                        self._lobby.removeChooser(player)
+                    except Exception:
+                        bs.printException(
+                            'Error: exception in Lobby.removeChooser()')
+
+            # *if* he was actually in the game, announce his departure
+            if team is not None:
+                bs.screenMessage(
+                    bs.Lstr(
+                        resource='playerLeftText',
+                        subs=[('${PLAYER}', player.getName(full=True))]))
+
+            # remove him from his team and session lists
+            # (he may not be on the team list since player are re-added to
+            # team lists every activity)
+            if team is not None and player in team.players:
+
+                # testing.. can remove this eventually
+                if isinstance(self, bs.FreeForAllSession):
+                    if len(team.players) != 1:
+                        bs.printError("expected 1 player in FFA team")
+                team.players.remove(player)
+
+            # remove player from any current activity
+            if activity is not None and player in activity.players:
+                activity.players.remove(player)
+
+                # run the activity callback unless its been finalized
+                if not activity.isFinalized():
+                    try:
+                        with bs.Context(activity):
+                            activity.onPlayerLeave(player)
+                    except Exception:
+                        bs.printException(
+                            'exception in onPlayerLeave for activity',
+                            activity)
+                else:
+                    bs.printError(
+                        "finalized activity in onPlayerLeave; shouldn't happen")
+
+                player._setActivity(None)
+
+                # reset the player - this will remove its actor-ref and clear
+                # its calls/etc
+                try:
+                    with bs.Context(activity):
+                        player._reset()
+                except Exception:
+                    bs.printException(
+                        'exception in player._reset in'
+                        ' onPlayerLeave for player',
+                        player)
+
+            # if we're a non-team session, remove the player's team completely
+            if not self._useTeams and team is not None:
+
+                # if the team's in an activity, call its onTeamLeave callback
+                if activity is not None and team in activity.teams:
+                    activity.teams.remove(team)
+
+                    if not activity.isFinalized():
+                        try:
+                            with bs.Context(activity):
+                                activity.onTeamLeave(team)
+                        except Exception:
+                            bs.printException(
+                                'exception in onTeamLeave for activity',
+                                activity)
+                    else:
+                        bs.printError(
+                            "finalized activity in onPlayerLeave p2"
+                            "; shouldn't happen")
+
+                    # clear the team's game-data (so dying stuff will
+                    # have proper context)
+                    try:
+                        with bs.Context(activity):
+                            team._resetGameData()
+                    except Exception:
+                        bs.printException(
+                            'exception clearing gameData for team:',
+                            team, 'for player:', player,
+                            'in activity:', activity)
+
+                # remove the team from the session
+                self.teams.remove(team)
+                try:
+                    with bs.Context(self):
+                        self.onTeamLeave(team)
+                except Exception:
+                    bs.printException(
+                        'exception in onTeamLeave for session', self)
+                # clear the team's session-data (so dying stuff will
+                # have proper context)
+                try:
+                    with bs.Context(self):
+                        team._resetSessionData()
+                except Exception:
+                    bs.printException(
+                        'exception clearing sessionData for team:', team,
+                        'in session:', self)
+
+            # now remove them from the session list
+            self.players.remove(player)
+
+        else:
+            print ('ERROR: Session.onPlayerLeave called'
+                   ' for player not in our list.')
+
+    def end(self):
+        """
+        Initiates an end to the session and a return to the main menu.
+        Note that this happens asynchronously, allowing the
+        session and its activities to shut down gracefully.
+        """
+        self._wantToEnd = True
+        if self._nextActivity is None:
+            self._launchEndActivity()
+
+    def _launchEndActivity(self):
+        with bs.Context(self):
+            if self._ending:
+                # ignore repeats unless its been a while..
+                sinceLast = bs.getRealTime()-self._launchEndActivityTime
+                if sinceLast < 3000:
+                    return
+                bs.printError(
+                    "_launchEndActivity called twice (sinceLast=" +
+                    str(sinceLast) + ")")
+            self._launchEndActivityTime = bs.getRealTime()
+            self.setActivity(bs.newActivity(EndSessionActivity))
+            self._wantToEnd = False
+            self._ending = True  # prevents further activity-mucking
+
+    def onTeamJoin(self, team):
+        'Called when a new bs.Team joins the session.'
+        pass
+
+    def onTeamLeave(self, team):
+        'Called when a bs.Team is leaving the session.'
+        pass
+
+    def _activityEnd(self, activity, results):
+        # run the subclass callback in the session context
+        try:
+            with bs.Context(self):
+                self.onActivityEnd(activity, results)
+        except Exception:
+            bs.printException(
+                'exception in onActivityEnd() for session', self, 'activity',
+                activity, 'with results', results)
+
+    def handleMessage(self, msg):
+        'General message handling; can be passed any message object.'
+        import bsLobby
+        if isinstance(msg, bsLobby.PlayerReadyMessage):
+            self._onPlayerReady(msg.chooser)
+
+        elif isinstance(msg, EndActivityMessage):
+
+            # if the whole session is shutting down, ignore these..
+            if self._ending:
+                return
+
+            # only pay attention if this is coming from our current activity..
+            if msg.activity is self._activityRetained:
+
+                # if this activity hasn't begun yet, just make note of what to
+                # do after we begin it
+                if not msg.activity._hasBegun:
+                    if not msg.activity._shouldEndImmediately or msg.force:
+                        msg.activity._shouldEndImmediately = True
+                        msg.activity._shouldEndImmediatelyResults = msg.results
+                        msg.activity._shouldEndImmediatelyDelay = msg.delay
+
+                # the activity has already begun; get ready to end it..
+                else:
+                    if (not msg.activity._hasEnded) or msg.force:
+                        # set a timer to set in motion this activity's demise
+                        msg.activity._hasEnded = True
+                        self._activityEndTimer = bs.Timer(
+                            msg.delay, bs.Call(
+                                self._activityEnd, msg.activity, msg.results),
+                            timeType='net')
+
+        elif isinstance(msg, PlayerProfilesChangedMessage):
+            # if we have a current activity with a lobby, ask it to
+            # reload profile
+            with bs.Context(self):
+                self._lobby.reloadProfiles()
+
+        else:
+            Session.handleMessage(self, msg)
+
+    def setActivity(self, activity):
+        """
+        Assign a new current bs.Activity for the session.
+        Note that this will not change the current context to the new
+        Activity's. Code must be run in the new activity's methods
+        (onTransitionIn, etc) to get it. (so you can't do
+        session.setActivity(foo) and then bs.newNode() to add a node to foo)
+        """
+
+        # sanity test - make sure this doesn't get called recursively
+        if self._inSetActivity:
+            raise Exception(
+                "Session.setActivity() cannot be called recursively.")
+
+        if activity.getSession() != bs.getSession():
+            raise Exception("provided activity's session is not current")
+
+        # quietly ignore this if we're currently going down
+        if self._ending:
+            return
+
+        if activity is self._activityRetained:
+            bs.printError("activity set to already-current activity")
+            return
+
+        if self._nextActivity is not None:
+            raise Exception(
+                "Activity switch already in progress (to " +
+                str(self._nextActivity) + ")")
+
+        self._inSetActivity = True
+
+        prevActivity = self._activityRetained
+
+        if prevActivity is not None:
+            with bs.Context(prevActivity):
+                prevGlobals = bs.getSharedObject('globals')
+        else:
+            prevGlobals = None
+
+        with bs.Context(activity):
+            g = bs.getSharedObject('globals')
+            g.useFixedVROverlay = activity._useFixedVROverlay
+            g.allowKickIdlePlayers = activity._allowKickIdlePlayers
+            if activity._inheritsSlowMotion and prevGlobals is not None:
+                g.slowMotion = prevGlobals.slowMotion
+            else:
+                g.slowMotion = activity._isSlowMotion
+            if activity._inheritsMusic and prevGlobals is not None:
+                g.musicContinuous = True  # prevents restarting same music
+                g.music = prevGlobals.music
+                g.musicCount += 1
+            if activity._inheritsCameraVROffset and prevGlobals is not None:
+                g.vrCameraOffset = prevGlobals.vrCameraOffset
+            if activity._inheritsVROverlayCenter and prevGlobals is not None:
+                g.vrOverlayCenter = prevGlobals.vrOverlayCenter
+                g.vrOverlayCenterEnabled = prevGlobals.vrOverlayCenterEnabled
+
+            # if they want to inherit tint from the previous activity..
+            if activity._inheritsTint and prevGlobals is not None:
+                g.tint = prevGlobals.tint
+                g.vignetteOuter = prevGlobals.vignetteOuter
+                g.vignetteInner = prevGlobals.vignetteInner
+            activity._hasTransitionedIn = True
+            activity.onTransitionIn()
+
+        self._nextActivity = activity
+
+        # if we have a current activity, tell it it's transitioning out;
+        # the next one will become current once this one dies.
+        if prevActivity is not None:
+            prevActivity._transitioningOut = True
+
+            # activity will be None until the next one begins
+            with bs.Context(prevActivity):
+                prevActivity.onTransitionOut()
+
+            # setting this to None should free up the old activity to die
+            # which will call _beginNextActivity.
+            # we can still access our old activity through self._activityWeak()
+            # to keep it up to date on player joins/departures/etc until it dies
+            self._activityRetained = None
+
+        # theres no existing activity; lets just go ahead with the begin call
+        else:
+            self._beginNextActivity()
+
+        # tell the C layer that this new activity is now 'foregrounded'
+        # this means that its globals node controls global stuff and
+        # stuff like console operations, keyboard shortcuts, etc will run in it
+        activity._activityData.makeForeground()
+
+        # we want to call _destroy() for the previous activity once it should
+        # tear itself down, clear out any self-refs, etc.  If the new activity
+        # has a transition-time, set it up to be called after that passes;
+        # otherwise call it immediately. After this call the activity should
+        # have no refs left to it and should die (which will trigger the next
+        # activity to run)
+        if prevActivity is not None:
+            if activity._transitionTime > 0:
+                # fixme - we should tweak the activity to not allow
+                # node-creation/etc when we call _destroy (or after)
+                with bs.Context('UI'):
+                    bs.realTimer(activity._transitionTime,
+                                 prevActivity._destroy)
+            # just run immediately
+            else:
+                prevActivity._destroy()
+
+        self._inSetActivity = False
+
+    def getActivity(self):
+        'Returns the current foreground activity for this session.'
+        return self._activityWeak()
+
+    def getCustomMenuEntries(self):
+        """
+        Subclasses can override this to provide custom menu entries.
+        The returned value should be a list of dicts, each containing
+        a 'label' and 'call' entry, with 'label' being the text for
+        the entry (translated) and 'call' being the callable to trigger
+        if the entry is pressed.
+        """
+        return []
+
+    def _requestPlayer(self, player):
+
+        # if we're ending, allow no new players
+        if self._ending:
+            return False
+
+        # ask the user
+        try:
+            with bs.Context(self):
+                result = self.onPlayerRequest(player)
+        except Exception:
+            bs.printException(
+                'exception in session onPlayerRequest call for', self)
+            result = False
+
+        # if the user said yes, add the player to the session list
+        if result:
+            self.players.append(player)
+
+            activity = self._activityWeak()
+
+            # if we have a current activity with a lobby,
+            # ask it to bring up a chooser for this player.
+            # otherwise they'll have to wait around for the next activity.
+            with bs.Context(self):
+                try:
+                    self._lobby.addChooser(player)
+                except Exception:
+                    bs.printException('exception in lobby.addChooser()')
+
+        return result
+
+    def onActivityEnd(self, activity, results):
+        """
+        Called when the current bs.Activity has ended.
+        The session should look at the results and start
+        another activity.
+        """
+        pass
+
+    def _beginNextActivity(self):
+        """
+        Called once the previous activity has been totally torn down;
+        this means we're ready to begin the next one
+        """
+        if self._nextActivity is not None:
+
+            # we store both a weak and a strong ref to the new activity;
+            # the strong is to keep it alive and the weak is so we can access
+            # it even after we've released the strong-ref to allow it to die
+            self._activityRetained = self._nextActivity
+            self._activityWeak = weakref.ref(self._nextActivity)
+            self._nextActivity = None
+            # lets kick out any players sitting in a chooser since
+            # new activities such as score screens could cover them up;
+            # better to have them rejoin
+            self._lobby.removeAllChoosersAndKickPlayers()
+            self._activityWeak()._begin(self)
+
+    def _onPlayerReady(self, chooser):
+        'Called when a bs.Player has chosen a character and is ready to join.'
+
+        lobby = chooser.getLobby()
+
+        activity = self._activityWeak()
+
+        # in joining activities, we wait till all choosers are ready
+        # and then create all players at once
+        if activity is not None and activity._isJoiningActivity:
+            if lobby.checkAllReady():
+                choosers = lobby.getChoosers()
+                minPlayers = self._minPlayers
+                if len(choosers) >= minPlayers:
+                    for chooser in lobby.getChoosers():
+                        self._addChosenPlayer(chooser)
+                    lobby.removeAllChoosers()
+                    # get our next activity going..
+                    self._activityEnd(activity, {})
+                else:
+                    bs.screenMessage(
+                        bs.Lstr(
+                            resource='notEnoughPlayersText',
+                            subs=[('${COUNT}', str(minPlayers))]),
+                        color=(1, 1, 0))
+                    bs.playSound(bs.getSound('error'))
+            else:
+                return
+        # otherwise just add players on the fly
+        else:
+            player = self._addChosenPlayer(chooser)
+            lobby.removeChooser(chooser.getPlayer())
+
+    def _addChosenPlayer(self, chooser):
+
+        player = chooser.getPlayer()
+        if not player in self.players:
+            bs.printError('player not found in session '
+                          'player-list after chooser selection')
+
+        activity = self._activityWeak()
+
+        # we need to reset the player's input here, as it is currently
+        # referencing the chooser which could inadvertantly keep it alive
+        player.resetInput()
+
+        # pass it to the current activity if it has already begun
+        # (otherwise it'll get passed once begin is called)
+        passToActivity = (
+            activity
+            is not None and activity.hasBegun()
+            and not activity._isJoiningActivity)
+
+        # if we're not allowing mid-game joins, dont' pass; just announce
+        # the arrival
+        if passToActivity:
+            if not self._allowMidActivityJoins:
+                passToActivity = False
+                with bs.Context(self):
+                    bs.screenMessage(
+                        bs.Lstr(resource='playerDelayedJoinText', subs=[
+                            ('${PLAYER}', player.getName(full=True))]),
+                        color=(0, 1, 0))
+
+        # if we're a non-team game, each player gets their own team
+        # (keeps mini-game coding simpler if we can always deal with teams)
+        if self._useTeams:
+            team = chooser.getTeam()
+        else:
+            ourTeamID = self._nextTeamID
+            team = bs.Team(
+                teamID=ourTeamID, name=chooser.getPlayer().getName(
+                    full=True, icon=False),
+                color=chooser.getColor())
+            self.teams.append(team)
+            self._nextTeamID += 1
+            try:
+                with bs.Context(self):
+                    self.onTeamJoin(team)
+            except Exception:
+                bs.printException('exception in onTeamJoin for', self)
+
+            if passToActivity:
+                if team in activity.teams:
+                    bs.printError(
+                        "Duplicate team ID in bs.Session._addChosenPlayer")
+                activity.teams.append(team)
+                try:
+                    with bs.Context(activity):
+                        activity.onTeamJoin(team)
+                except Exception:
+                    bs.printException(
+                        'ERROR: exception in onTeamJoin for', activity)
+
+        player._setData(team=team,
+                        character=chooser.getCharacterName(),
+                        color=chooser.getColor(),
+                        highlight=chooser.getHighlight())
+
+        self.scoreSet.registerPlayer(player)
+
+        if passToActivity:
+
+            if isinstance(self, bs.FreeForAllSession):
+                if len(player.getTeam().players) != 0:
+                    bs.printError("expected 0 players in FFA team")
+
+            # dont actually add the player to their team list if we're not
+            # in an activity; (players get (re)added to their team lists
+            # when the activity begins)
+            player.getTeam().players.append(player)
+
+            if player in activity.players:
+                bs.printException(
+                    'Duplicate player in bs.Session._addChosenPlayer:', player)
+            else:
+                activity.players.append(player)
+                player._setActivity(activity)
+                activity._createPlayerNode(player)
+                try:
+                    with bs.Context(activity):
+                        activity.onPlayerJoin(player)
+                except Exception:
+                    bs.printException('Error on onPlayerJoin for', activity)
+        return player
+
+
+class EndActivityMessage(object):
+    def __init__(self, activity, results=None, delay=0, force=False):
+        self.activity = activity
+        self.results = results
+        self.delay = delay
+        self.force = force
+
+
+class PlayerProfilesChangedMessage(object):
+    """
+    Signifies player profiles may have changed and should be reloaded if they
+    are being used.
+    """
+    pass
+
+
+class Activity(object):
+    """
+    category: Game Flow Classes
+
+    Units wrangled by a bs.Session.  Examples of Activities include games,
+    score-screens, cutscenes, etc. A bs.Session has one 'current' Activity at
+    any time, though their existence can overlap during transitions. 
+
+    Attributes:
+
+       settings
+          The settings dict passed in when the activity was made.
+
+       teams
+          The list of bs.Teams in the activity. This gets populated just before
+          onBegin() is called and is updated automatically as players join or
+          leave the game. (at least in free-for-all mode where every player gets
+          their own team; in teams mode there are always 2 teams regardless of
+          the player count).
+
+       players
+          The list of bs.Players in the activity. This gets populated just
+          before onBegin() is called and is updated automatically as players
+          join or leave the game.
+    """
+
+    def __init__(self, settings={}):
+        """
+        Creates an activity in the current bs.Session.
+        The activity will not be actually run until bs.Session.setActivity()
+        is called.
+        'settings' should be a dict of key/value pairs specific to the activity.
+
+        Activities should preload as much of their media/etc as possible in
+        their constructor, but none of it should actually be used until they
+        are transitioned in.
+        """
+
+        # first thing, generate our link to our C layer equivalent.
+        self._activityData = bsInternal._registerActivity(self)
+        session = bs.getSession()
+        self._session = weakref.ref(session)
+
+        if session is None:
+            raise Exception("No current session")
+        if type(settings) is not dict:
+            raise Exception("expected dict for settings")
+        if bs.getActivity(exceptionOnNone=False) is not self:
+            raise Exception('invalid context state')
+
+        self.settings = settings
+
+        self._hasTransitionedIn = False
+        self._hasBegun = False
+        self._hasEnded = False
+        self._shouldEndImmediately = False
+
+        self._isWaitingForContinue = False
+        self._continueCost = bsInternal._getAccountMiscReadVal(
+            'continueStartCost',
+            25)
+        self._continueCostMult = bsInternal._getAccountMiscReadVal(
+            'continuesMult',
+            2)
+        self._continueCostOffset = bsInternal._getAccountMiscReadVal(
+            'continuesOffset',
+            0)
+
+        self._finalized = False
+
+        # whether to print every time a player dies.  This can be pertinant
+        # in games such as Death-Match but can be annoying in games where it
+        # doesn't matter
+        self.announcePlayerDeaths = False
+
+        # joining activities are for waiting for initial player joins
+        # they are treated slightly differently than regular activities,
+        # mainly in that all players are passed to the activity at once
+        # instead of as each joins.
+        self._isJoiningActivity = False
+
+        # whether game-time should still progress when in menus/etc
+        self._allowPausing = False
+
+        # whether idle players can potentially be kicked (should not happen in
+        # menus/etc)
+        self._allowKickIdlePlayers = True
+
+        # in vr mode, this determines whether overlay nodes (text,images,etc)
+        # are created at a fixed position in space or one that moves based on
+        # the current map. generally this should be on for games and off for
+        # transitions/score-screens/etc
+        # that persist between maps.
+        self._useFixedVROverlay = False
+
+        # if True, runs in slow motion and turns down sound pitch
+        self._isSlowMotion = False
+
+        # set this to True to inherit slow motion setting from previous activity
+        # (useful for transitions to avoid hitches)
+        self._inheritsSlowMotion = False
+
+        # set this to True to keep playing the music from the previous activity
+        # (without even restarting it)
+        self._inheritsMusic = False
+
+        # set this to true to inherit VR camera offsets from the previous
+        # activity (useful for preventing sporadic camera movement
+        # during transitions)
+        self._inheritsCameraVROffset = False
+
+        # set this to true to inherit (non-fixed) VR overlay positioning from
+        # the previous activity (useful for prevent sporadic overlay jostling
+        # during transitions)
+        self._inheritsVROverlayCenter = False
+
+        # set this to true to inherit screen tint/vignette colors from the
+        # previous activity (useful to prevent sudden color changes during
+        # transitions)
+        self._inheritsTint = False
+
+        # if the activity fades or transitions in, it should set the length of
+        # time here so that previous activities will be kept alive for that
+        # long (avoiding 'holes' in the screen)
+        # note that this time value is in real-time; not game-time.
+        self._transitionTime = 0
+
+        # this gets set once another activity has begun transitioning in but
+        # before this one is killed. (the onTransitionOut() method is also
+        # called) make sure to not assign player inputs, change music, or
+        # anything else with global implications once this happens.
+        self._transitioningOut = False
+
+        # a handy place to put most actors; this list is pruned of dead
+        # actors regularly and these actors are insta-killed as the activity
+        # is dying.
+        self._actorRefs = []
+
+        self._actorWeakRefs = []
+        self._ownedNodes = []
+
+        self._lastDeadObjectPruneTime = bs.getGameTime()
+
+        # this stuff gets filled in just before onBegin() is called
+        self.teams = []
+        self.players = []
+        self.scoreSet = None
+
+        self._useLobby = True
+        self._lobby = None
+
+    def onFinalize(self):
+        """
+        Called when your activity is being finalized.
+        If your activity has created anything explicitly that may be retaining
+        a strong reference to the activity and preventing it from dying, you
+        should tear that down here. From this point on your activity's sole
+        purpose in life is to hit zero references and die so the next activity
+        can begin.
+        """
+        pass
+
+    def isFinalized(self):
+        """
+        The activity is set as finalized when shutting down.
+        At this point no new nodes, timers, etc should be made,
+        run, etc, and the activity should be considered to be a 'zombie'.
+        """
+        return self._finalized
+
+    def __del__(self):
+
+        # if the activity has been run, we've explicitly cleaned it up,
+        # but we need to run finalize here for un-run activities
+        if not self._finalized:
+            with bs.Context('empty'):
+                self._finalize()
+
+        # since we're mostly between activities at this point, lets run a cycle
+        # of garbage collection; hopefully it won't cause hitches here
+        bsUtils.garbageCollect(sessionEnd=False)
+
+        # now that our object is officially gonna be dead, tell the session to
+        # fire up the next activity
+        if self._transitioningOut:
+            session = self._session()
+            if session is not None:
+                with bs.Context(session):
+                    if getattr(self, '_canShowAdOnDeath', False):
+                        bsUtils._callAfterAd(session._beginNextActivity)
+                    else:
+                        bs.pushCall(session._beginNextActivity)
+
+    def _getPlayerIcon(self, player):
+        # do we want to cache these somehow?..
+        info = player._getIconInfo()
+        return {'texture': bs.getTexture(info['texture']),
+                'tintTexture': bs.getTexture(info['tintTexture']),
+                'tintColor': info['tintColor'],
+                'tint2Color': info['tint2Color']}
+
+    def _destroy(self):
+
+        # create a real-timer that watches a weak-ref of this activity
+        # and reports any lingering references keeping it alive..
+        # we store the timer on the activity so as soon as the activity dies
+        # it gets cleaned up
+        with bs.Context('UI'):
+            r = weakref.ref(self)
+            self._activityDeathCheckTimer = bs.Timer(
+                5000, bs.Call(self._checkActivityDeath, r, [0]),
+                repeat=True, timeType='real')
+
+        # run _finalize in an empty context; nothing should be happening in
+        # there except deleting things which requires no context.
+        # (plus, _finalize() runs in the destructor for un-run activities
+        # and we can't properly provide context in that situation anyway; might
+        # as well be consistent)
+        if not self._finalized:
+            with bs.Context('empty'):
+                self._finalize()
+        else:
+            raise Exception("_destroy() called multiple times")
+
+    def _continueChoice(self, doContinue):
+        self._isWaitingForContinue = False
+        if self.hasEnded():
+            return
+        with bs.Context(self):
+            if doContinue:
+                bs.playSound(bs.getSound('shieldUp'))
+                bs.playSound(bs.getSound('cashRegister'))
+                bsInternal._addTransaction({'type': 'CONTINUE',
+                                            'cost': self._continueCost})
+                bsInternal._runTransactions()
+                self._continueCost = (
+                    self._continueCost * self._continueCostMult
+                    + self._continueCostOffset)
+                self.onContinue()
+            else:
+                self.endGame()
+
+    def isWaitingForContinue(self):
+        """Returns whether or not this activity is currently waiting for the
+        player to continue (or timeout)"""
+        return self._isWaitingForContinue
+
+    def continueOrEndGame(self):
+        """If continues are allowed, prompts the player to purchase a continue
+        and calls either endGame or continueGame depending on the result"""
+
+        import bsUI
+
+        try:
+            if bsInternal._getAccountMiscReadVal('enableContinues', False):
+
+                # we only support continuing in non-tournament games
+                try:
+                    tournamentID = self.getSession()._tournamentID
+                except Exception:
+                    tournamentID = None
+                if tournamentID is None:
+
+                    # we currently only support continuing in sequential
+                    # co-op campaigns
+                    if isinstance(self.getSession(), bs.CoopSession):
+                        if self.getSession()._campaign.isSequential():
+                            g = bs.getSharedObject('globals')
+
+                            # only attempt this if we're not currently paused
+                            # and there appears to be no UI
+                            if (not g.paused
+                                    and bsUI.uiGlobals['mainMenuWindow']
+                                    is None
+                                    or not bsUI.uiGlobals['mainMenuWindow']
+                                    .exists()):
+                                self._isWaitingForContinue = True
+                                with bs.Context('UI'):
+                                    bs.realTimer(
+                                        500, lambda: bsUI.ContinueWindow(
+                                            self, self._continueCost,
+                                            continueCall=bs.WeakCall(
+                                                self._continueChoice, True),
+                                            cancelCall=bs.WeakCall(
+                                                self._continueChoice, False)))
+                                return
+
+        except Exception:
+            bs.printException("error continuing game")
+
+        self.endGame()
+
+    @classmethod
+    def _checkActivityDeath(cls, activityRef, counter):
+        try:
+            import gc
+            import types
+            a = activityRef()
+            print 'ERROR: Activity is not dying when expected:', a,\
+                '(warning '+str(counter[0]+1)+')'
+            print 'This means something is still strong-referencing it.'
+
+            counter[0] += 1
+
+            # FIXME - running the code below shows us references but winds up
+            # keeping the object alive... need to figure out why.
+            # for now we just print refs if the count gets to 3, and then we
+            # kill the app at 4 so it doesn't matter anyway..
+            if counter[0] == 3:
+                print 'Activity references for', a, ':'
+                refs = list(gc.get_referrers(a))
+                i = 1
+                for ref in refs:
+                    if type(ref) is types.FrameType:
+                        continue
+                    print '  reference', i, ':', ref
+                    i += 1
+            if counter[0] == 4:
+                print 'Killing app due to stuck activity... :-('
+                bs.quit()
+
+        except Exception:
+            bs.printException('exception on _checkActivityDeath:')
+
+    def _finalize(self):
+
+        self._finalized = True
+
+        # do some default cleanup
+        try:
+
+            try:
+                self.onFinalize()
+            except Exception:
+                bs.printException(
+                    'Exception in onFinalize() for activity', self)
+
+            # send finalize notices to all remaining actors
+            for actorRef in self._actorWeakRefs:
+                try:
+                    actor = actorRef()
+                    if actor is not None:
+                        actor.onFinalize()
+                except Exception:
+                    bs.printException('Exception on bs.Activity._finalize()'
+                                      ' in actor onFinalize():', actorRef())
+
+            # reset all players (releases any attached actors, clears
+            # game-data, etc)
+            for player in self.players:
+                if player.exists():
+                    try:
+                        player._reset()
+                        player._setActivity(None)
+                    except Exception:
+                        bs.printException('Exception on bs.Activity._finalize()'
+                                          ' resetting player:', player)
+
+            # ditto with teams
+            for team in self.teams:
+                try:
+                    team._reset()
+                except Exception:
+                    bs.printException(
+                        'Exception on bs.Activity._finalize() resetting team:',
+                        player)
+
+        except Exception:
+            bs.printException('Exception during bs.Activity._finalize():')
+
+        # regardless of what happened here, we want to destroy our data, as
+        # our activity might not go down if we don't. # This will kill all
+        # timers, nodes, etc, which should clear up any remaining refs to our
+        # actors and activity and allow us to die peacefully.
+        try:
+            self._activityData.destroy()
+        except Exception:
+            bs.printException(
+                'Exception during bs.Activity._finalize() destroying data:')
+
+    def _pruneDeadObjects(self):
+        try:
+            self._actorRefs = [a for a in self._actorRefs if a.exists()]
+        except Exception:
+            bs.printException('exc pruning '+str(self._actorRefs))
+        self._actorWeakRefs = [a for a in self._actorWeakRefs
+                               if a() is not None and a().exists()]
+        self._lastDeadObjectPruneTime = bs.getGameTime()
+
+    def _retainActor(self, a):
+        if not isinstance(a, bs.Actor):
+            raise Exception("non-actor passed to _retainActor")
+        if (self.hasTransitionedIn() and bs.getGameTime()
+                - self._lastDeadObjectPruneTime > 10000):
+            bs.printError('it looks like nodes/actors are not'
+                          ' being pruned in your activity;'
+                          ' did you call Activity.onTransitionIn()'
+                          ' from your subclass?; '
+                          + str(self) + ' (loc. a)')
+        self._actorRefs.append(a)
+
+    def _addActorWeakRef(self, a):
+        if not isinstance(a, bs.Actor):
+            raise Exception("non-actor passed to _addActorWeakRef")
+        if (self.hasTransitionedIn()
+                and bs.getGameTime() - self._lastDeadObjectPruneTime > 10000):
+            bs.printError('it looks like nodes/actors are '
+                          'not being pruned in your activity;'
+                          ' did you call Activity.onTransitionIn()'
+                          ' from your subclass?; '+ str(self) + ' (loc. b)')
+        self._actorWeakRefs.append(weakref.ref(a))
+
+    def getSession(self):
+        """
+        Returns the bs.Session this activity belongs to.
+        If the session no longer exists, returns None.
+        """
+        session = self._session()
+        return session
+
+    def onPlayerJoin(self, player):
+        'Called for all new bs.Players (including the initial set of them).'
+        pass
+
+    def onPlayerLeave(self, player):
+        'Called when a player is leaving the activity.'
+        pass
+
+    def onTeamJoin(self, team):
+        """
+        Called when a new bs.Team enters the activity
+        (including the initial set of them).'
+        """
+        pass
+
+    def onTeamLeave(self, team):
+        'Called when a bs.Team leaves the activity.'
+        pass
+
+    def onTransitionIn(self):
+        """
+        Called when your activity is first becoming visible;
+        It should fade in backgrounds, start playing music, etc.
+        It does not yet have access to bs.Players or bs.Teams, however,
+        until bs.Activity.onBegin() is called; they are still owned
+        by the previous activity up until this point.
+        """
+
+        self._calledActivityOnTransitionIn = True
+
+        # start pruning our transient actors periodically
+        self._pruneDeadObjectsTimer = bs.Timer(
+            5000, bs.WeakCall(self._pruneDeadObjects), repeat=True)
+        self._pruneDeadObjects()
+
+        # also start our low-level scene-graph running
+        self._activityData.start()
+
+    def onTransitionOut(self):
+        """
+        Called when your activity starts transitioning out and a new
+        activity is on the way in.  Note that this may happen at any
+        time even if finish() has not been called.
+        """
+        pass
+
+    def onBegin(self):
+        """
+        Called once the previous bs.Activity has finished transitioning out.
+        At this point the activity's initial players and teams are filled in
+        and it should begin its actual game logic.
+        """
+        self._calledActivityOnBegin = True
+
+    def onContinue(self):
+        """
+        This is called if a game supports and offers a continue and the player
+        accepts.  In this case the player should be given an extra life or
+        whatever is relevant to keep the game going.
+        """
+        pass
+
+    def handleMessage(self, msg):
+        'General message handling; can be passed any message object.'
+        pass
+
+    def end(self, results=None, delay=0, force=False):
+        """
+        Commences activity shutdown and delivers results to the bs.Session.
+        'delay' is the time delay before the activity actually ends
+        (in milliseconds). Further calls to end() will be ignored up until
+        this time, unless 'force' is True, in which case the new results
+        will replace the old.
+        """
+
+        # if results is a standard team-game-results, associate it with us
+        # so it can grab our score prefs
+        if isinstance(results, bs.TeamGameResults):
+            results._setGame(self)
+
+        # if we had a standard time-limit that had not expired, stop it so
+        # it doesnt tick annoyingly
+        if (hasattr(self, '_standardTimeLimitTime')
+                and self._standardTimeLimitTime > 0):
+            self._standardTimeLimitTimer = self._standardTimeLimitText = None
+
+        # ditto with tournament time limits
+        if (hasattr(self, '_tournamentTimeLimitTime')
+                and self._tournamentTimeLimitTime > 0):
+            self._tournamentTimeLimitTimer = self._tournamentTimeLimitText = \
+                self._tournamentTimeLimitTitleText = None
+
+        self.getSession()\
+            .handleMessage(EndActivityMessage(self, results, delay, force))
+
+    def hasTransitionedIn(self):
+        'Returns whether onTransitionIn() has been called for this activity.'
+        return self._hasTransitionedIn
+
+    def hasBegun(self):
+        'Returns whether onBegin() has been called for this activity.'
+        return self._hasBegun
+
+    def hasEnded(self):
+        'Returns whether end() has been called for this activity.'
+        return self._hasEnded
+
+    def isTransitioningOut(self):
+        'Returns whether onTransitionOut() has been called for this activity.'
+        return self._transitioningOut
+
+    def _createPlayerNode(self, player):
+        with bs.Context(self):
+            player.gameData['_playerNode'] = bs.NodeActor(
+                bs.newNode('player', attrs={'playerID': player.getID()}))
+
+    def _begin(self, session):
+        'private call to set up onBegin'
+
+        if self._hasBegun:
+            bs.printError("_begin called twice; this shouldn't happen")
+            return
+
+        self.scoreSet = session.scoreSet
+
+        # operate on the subset of session players who have passed team/char
+        # selection
+        players = []
+        chooserPlayers = []
+        for p in session.players:
+            if p.exists():
+                if p.getTeam() is not None:
+                    p.resetInput()
+                    players.append(p)
+                else:
+                    # simply ignore players sitting in a player-chooser
+                    # this technically shouldn't happen anymore since choosers
+                    # now get cleared when starting new activities...
+                    chooserPlayers.append(p)
+            else:
+                bs.printError("got nonexistant player in Activity._begin()")
+
+        # add teams in one by one and send team-joined messages for each
+        for team in session.teams:
+            if team in self.teams:
+                raise Exception("Duplicate Team Entry")
+            self.teams.append(team)
+            try:
+                with bs.Context(self):
+                    self.onTeamJoin(team)
+            except Exception:
+                bs.printException('ERROR: exception in onTeamJoin for', self)
+
+        # now add each player to the activity and to its team's list,
+        # and send player-joined messages for each
+        for player in players:
+            self.players.append(player)
+
+            player.getTeam().players.append(player)
+            player._setActivity(self)
+            self._createPlayerNode(player)
+            try:
+                with bs.Context(self):
+                    self.onPlayerJoin(player)
+            except Exception:
+                bs.printException('exception in onPlayerJoin for', self)
+
+        with bs.Context(self):
+            # and finally tell the game to start
+            self._hasBegun = True
+            self.onBegin()
+
+        # make sure that bs.Activity.onTransitionIn() got called at some point
+        if not hasattr(self, '_calledActivityOnTransitionIn'):
+            bs.printError(
+                "bs.Activity.onTransitionIn() never got called for " +
+                str(self) +
+                "; did you forget to call it in your onTransitionIn override?")
+        else:
+            del self._calledActivityOnTransitionIn
+
+        # make sure that bs.Activity.onBegin() got called at some point
+        if not hasattr(self, '_calledActivityOnBegin'):
+            bs.printError(
+                "bs.Activity.onBegin() never got called for " + str(self) +
+                "; did you forget to call it in your onBegin override?")
+        else:
+            del self._calledActivityOnBegin
+
+        # if the whole session wants to die and was waiting on us, can get
+        # that going now..
+        if session._wantToEnd:
+            session._launchEndActivity()
+        else:
+            # otherwise, if we've already been told to die, do so now..
+            if self._shouldEndImmediately:
+                self.end(self._shouldEndImmediatelyResults,
+                         self._shouldEndImmediatelyDelay)
+
+
+class EndSessionActivity(Activity):
+    """ Special activity to fade out and end the current session """
+
+    def __init__(self, settings={}):
+        Activity.__init__(self, settings)
+        self._transitionTime = 250  # keeps prev activity alive while we fadeout
+        self._useLobby = False
+        self._inheritsTint = True
+        self._inheritsSlowMotion = True
+        self._inheritsCameraVROffset = True
+        self._inheritsVROverlayCenter = True
+
+    def onTransitionIn(self):
+        Activity.onTransitionIn(self)
+        bsInternal._fadeScreen(False, time=250)
+        bsInternal._lockAllInput()
+
+    def onBegin(self):
+        import bsMainMenu
+        Activity.onBegin(self)
+        bsInternal._unlockAllInput()
+        bsUtils._callAfterAd(bs.Call(bsInternal._newHostSession,
+                                     bsMainMenu.MainMenuSession))
+
+
+class JoiningActivity(Activity):
+    """
+    Standard joining activity that shows tips and waits for all players to join.
+    """
+
+    def __init__(self, settings={}):
+        Activity.__init__(self, settings)
+
+        # this activity is a special 'joiner' activity
+        # - it will get shut down as soon as all players have checked ready
+        self._isJoiningActivity = True
+
+        # players may be idle waiting for joiners; lets not kick them for it
+        self._allowKickIdlePlayers = False
+
+        # in vr mode we dont want stuff moving around
+        self._useFixedVROverlay = True
+
+    def onTransitionIn(self):
+        Activity.onTransitionIn(self)
+        self._background = bsUtils.Background(
+            fadeTime=500, startFaded=True, showLogo=True)
+        self._tipsText = bsUtils.TipsText()
+        bs.playMusic('CharSelect')
+
+        self._joinInfo = self.getSession()._lobby._createJoinInfo()
+
+        bsInternal._setAnalyticsScreen('Joining Screen')
+
+
+class TransitionActivity(Activity):
+    """
+    A simple overlay fade out/in; useful as a bare minimum transition
+    between two level based activities.
+    """
+
+    def __init__(self, settings={}):
+        Activity.__init__(self, settings)
+        self._transitionTime = 500  # keeps prev activity alive while we fade in
+        self._inheritsSlowMotion = True  # dont change..
+        self._inheritsTint = True  # dont change
+        self._inheritsCameraVROffset = True  # dont change
+        self._inheritsVROverlayCenter = True
+        self._useFixedVROverlay = True
+
+    def onTransitionIn(self):
+        Activity.onTransitionIn(self)
+        self._background = bsUtils.Background(
+            fadeTime=500, startFaded=False, showLogo=False)
+
+    def onBegin(self):
+        Activity.onBegin(self)
+        # die almost immediately
+        bs.gameTimer(100, self.end)
+
+
+class ScoreScreenActivity(Activity):
+    """
+    A standard score screen that fades in and shows stuff for a while.
+    After a specified delay, player input is assigned to send an
+    EndActivityMessage.
+    """
+
+    def __init__(self, settings={}):
+        Activity.__init__(self, settings)
+        self._transitionTime = 500
+        self._birthTime = bs.getGameTime()
+        self._minViewTime = 5000
+        self._inheritsTint = True
+        self._inheritsCameraVROffset = True
+        self._useFixedVROverlay = True
+        self._allowServerRestart = False
+
+    def onPlayerJoin(self, player):
+        Activity.onPlayerJoin(self, player)
+        timeTillAssign = max(
+            0, self._birthTime+self._minViewTime-bs.getGameTime())
+        # if we're still kicking at the end of our assign-delay, assign this
+        # guy's input to trigger us
+        bs.gameTimer(timeTillAssign, bs.WeakCall(self._safeAssign, player))
+
+    def _safeAssign(self, player):
+        # just to be extra careful, don't assign if we're transitioning out..
+        # (though theoretically that would be ok)
+        if not self.isTransitioningOut() and player.exists():
+            player.assignInputCall(
+                ('jumpPress', 'punchPress', 'bombPress', 'pickUpPress'),
+                self._playerPress)
+
+    def _playerPress(self):
+
+        # If we're running in server-mode and the config is dirty, this is a
+        # good time to either restart or quit
+        if self._allowServerRestart and bsUtils._gServerConfigDirty:
+            if bsUtils._gServerConfig.get('quit', False):
+                if not getattr(self, '_kickedOffServerShutdown', False):
+                    if bsUtils._gServerConfig.get('quitReason') == 'restarting':
+                        # FIXME - should add a server-screen-message call
+                        # or something.
+                        bsInternal._chatMessage(
+                            bs.Lstr(resource='internal.serverRestartingText')
+                            .evaluate())
+                        print 'Exiting for server-restart at ' \
+                            + time.strftime('%c')
+                    else:
+                        print 'Exiting for server-shutdown at ' \
+                            + time.strftime('%c')
+                    with bs.Context('UI'):
+                        bs.realTimer(2000, bs.quit)
+                    self._kickedOffServerShutdown = True
+                    return
+            else:
+                if not getattr(self, '_kickedOffServerRestart', False):
+                    print 'Running updated server config at ' \
+                        + time.strftime('%c')
+                    with bs.Context('UI'):
+                        bs.realTimer(1000, bs.Call(
+                            bs.pushCall, bsUtils._runServer))
+                    self._kickedOffServerRestart = True
+                    return
+
+        self.end()
+
+    def onTransitionIn(self, music='Scores', showTips=True):
+        Activity.onTransitionIn(self)
+        self._background = bsUtils.Background(
+            fadeTime=500, startFaded=False, showLogo=True)
+        if showTips:
+            self._tipsText = bsUtils.TipsText()
+        bs.playMusic(music)
+
+    def onBegin(self, customContinueMessage=None):
+        Activity.onBegin(self)
+        # pop up a 'press any button to continue' statement after our
+        # min-view-time show a 'press any button to continue..'
+        # thing after a bit..
+        if bs.getEnvironment()['interfaceType'] == 'large':
+            # FIXME - need a better way to determine whether we've probably
+            # got a keyboard
+            s = bs.Lstr(resource='pressAnyKeyButtonText')
+        else:
+            s = bs.Lstr(resource='pressAnyButtonText')
+
+        bsUtils.Text(customContinueMessage if customContinueMessage else s,
+                     vAttach='bottom',
+                     hAlign='center',
+                     flash=True,
+                     vrDepth=50,
+                     position=(0, 10),
+                     scale=0.8,
+                     color=(0.5, 0.7, 0.5, 0.5),
+                     transition='inBottomSlow',
+                     transitionDelay=self._minViewTime).autoRetain()
+
+
+class GameActivity(Activity):
+    """
+    category: Game Flow Classes
+
+    Common base class for all game activities; whether of
+    the free-for-all, co-op, or teams variety.
+    """
+    tips = []
+
+    @classmethod
+    def createConfigUI(cls, sessionType, config, completionCall):
+        """
+        Launch a UI to configure settings for this game type under the given
+        bs.Session type.
+
+        'config' should be an existing config dict (specifies 'edit' mode) or
+        None (specifies 'add' mode).
+
+        'completionCall' will be called with a filled-out config dict on success
+        or None on cancel.
+
+        Generally subclasses don't need to override this; if they override
+        bs.GameActivity.getSettings() and bs.GameActivity.getSupportedMaps()
+        they can just rely on the default implementation here
+        which calls those functions.
+        """
+        import bsUI
+        bsUI.standardGameConfigUI(cls, sessionType, config, completionCall)
+
+    @classmethod
+    def getScoreInfo(cls):
+        """
+        Games should override this to provide info about their scoring setup.
+        They should return a dict containing any of the following (missing
+        values will be default):
+
+        'scoreName': a label shown to the user for scores; 'Score',
+            'Time Survived', etc. 'Score' is the default.
+
+        'lowerIsBetter': a boolean telling whether lower scores are preferable
+            instead of higher (the default).
+
+        'noneIsWinner': specifies whether a score value of None is considered
+            better than other scores or worse. Default is False.
+
+        'scoreType': can be 'seconds', 'milliseconds', or 'points'.
+
+        'scoreVersion': to change high-score lists used by a game without
+            renaming the game, change this. Defaults to empty string.
+        """
+        return {}
+
+    @classmethod
+    def getResolvedScoreInfo(cls):
+        """
+        Call this to return a game's score info with all missing values
+        filled in with defaults. This should not be overridden; override
+        getScoreInfo() instead.
+        """
+        values = cls.getScoreInfo()
+        if 'scoreName' not in values:
+            values['scoreName'] = 'Score'
+        if 'lowerIsBetter' not in values:
+            values['lowerIsBetter'] = False
+        if 'noneIsWinner' not in values:
+            values['noneIsWinner'] = False
+        if 'scoreType' not in values:
+            values['scoreType'] = 'points'
+        if 'scoreVersion' not in values:
+            values['scoreVersion'] = ''
+
+        if values['scoreType'] not in ['seconds', 'milliseconds', 'points']:
+            raise Exception(
+                "invalid scoreType value: '"+values['scoreType']+"'")
+
+        # make sure they didnt misspell anything in there..
+        for name in values.keys():
+            if name not in (
+                'scoreName', 'lowerIsBetter', 'noneIsWinner', 'scoreType',
+                    'scoreVersion'):
+                print 'WARNING: invalid key in scoreInfo: "'+name+'"'
+
+        return values
+
+    @classmethod
+    def getName(cls):
+        """
+        Return a name for this game type in English.
+        """
+        try:
+            return cls.__module__.replace('_', ' ')
+        except Exception:
+            return 'Untitled Game'
+
+    @classmethod
+    def getDisplayString(cls, settings=None):
+        """
+        Return a descriptive name for this game/settings combo.
+        Subclasses should override getName(); not this.
+        """
+        name = bs.Lstr(translate=('gameNames', cls.getName()))
+
+        # a few substitutions for 'Epic', 'Solo' etc modes..
+        # FIXME: should provide a way for game types to define filters of
+        # their own..
+        if settings is not None:
+            if 'Solo Mode' in settings and settings['Solo Mode']:
+                name = bs.Lstr(resource='soloNameFilterText',
+                               subs=[('${NAME}', name)])
+            if 'Epic Mode' in settings and settings['Epic Mode']:
+                name = bs.Lstr(resource='epicNameFilterText',
+                               subs=[('${NAME}', name)])
+
+        return name
+
+    @classmethod
+    def getTeamDisplayString(cls, name):
+        """
+        Given a team name, returns a localized version of it.
+        """
+        return bs.Lstr(translate=('teamNames', name))
+
+    @classmethod
+    def getDescription(cls, sessionType):
+        """
+        Subclasses should override this to return a description for this
+        activity type (in English) within the context of the given
+        bs.Session type.
+        """
+        return ''
+
+    @classmethod
+    def getDescriptionDisplayString(cls, sessionType):
+        """
+        Return a translated version of getDescription().
+        Sub-classes should override getDescription(); not this.
+        """
+        description = cls.getDescription(sessionType)
+        return bs.Lstr(translate=('gameDescriptions', description))
+
+    @classmethod
+    def getSettings(cls, sessionType):
+        """
+        Called by the default bs.GameActivity.createConfigUI() implementation;
+        should return a dict of config options to be presented
+        to the user for the given bs.Session type.
+
+        The format for settings is a list of 2-member tuples consisting
+        of a name and a dict of options.
+
+        Available Setting Options:
+
+        'default': This determines the default value as well as the
+            type (int, float, or bool)
+
+        'minValue': Minimum value for int/float settings.
+
+        'maxValue': Maximum value for int/float settings.
+
+        'choices': A list of 2-member name/value tuples which the user can
+            toggle through.
+
+        'increment': Value increment for int/float settings.
+
+        # example getSettings() implementation for a capture-the-flag game:
+        @classmethod
+        def getSettings(cls,sessionType):
+            return [("Score to Win",{'default':3,
+                                     'minValue':1}),
+                    ("Flag Touch Return Time",{'default':0,
+                                               'minValue':0,
+                                               'increment':1}),
+                    ("Flag Idle Return Time",{'default':30,
+                                              'minValue':5,
+                                              'increment':5}),
+                    ("Time Limit",{'default':0,
+                                   'choices':[('None',0),
+                                              ('1 Minute',60),
+                                              ('2 Minutes',120),
+                                              ('5 Minutes',300),
+                                              ('10 Minutes',600),
+                                              ('20 Minutes',1200)]}),
+                    ("Respawn Times",{'default':1.0,
+                                      'choices':[('Shorter',0.25),
+                                                 ('Short',0.5),
+                                                 ('Normal',1.0),
+                                                 ('Long',2.0),
+                                                 ('Longer',4.0)]}),
+                    ("Epic Mode",{'default':False})]
+        """
+        return {}
+
+    @classmethod
+    def getSupportedMaps(cls, sessionType):
+        """
+        Called by the default bs.GameActivity.createConfigUI() implementation;
+        should return a list of map names valid for this game-type
+        for the given bs.Session type.
+        """
+        import bsMap
+        return bsMap.getMapsSupportingPlayType("melee")
+
+    @classmethod
+    def filterGameName(cls):
+        """
+        Given a game name, game type
+        """
+
+    @classmethod
+    def getConfigDisplayString(cls, config):
+        """
+        Given a game config dict, return a short description for it.
+        This is used when viewing game-lists or showing what game
+        is up next in a series.
+        """
+        import bsMap
+        name = cls.getDisplayString(config['settings'])
+
+        # in newer configs, map is in settings; it used to be in the config root
+        if 'map' in config['settings']:
+            s = bs.Lstr(
+                value="${NAME} @ ${MAP}",
+                subs=[('${NAME}', name),
+                      ('${MAP}', bsMap.getMapDisplayString(
+                          bsMap.getFilteredMapName(
+                              config['settings']['map'])))])
+        elif 'map' in config:
+            s = bs.Lstr(
+                value="${NAME} @ ${MAP}",
+                subs=[('${NAME}', name),
+                      ('${MAP}', bsMap.getMapDisplayString(
+                          bsMap.getFilteredMapName(config['map'])))])
+        else:
+            print 'invalid game config - expected map entry under settings'
+            s = '???'
+        return s
+
+    @classmethod
+    def supportsSessionType(cls, sessionType):
+        """
+        Return whether this game type can be played
+        in the provided bs.Session subtype.
+        """
+        return True if issubclass(sessionType, bs.TeamsSession) else False
+
+    def __init__(self, settings={}):
+        """
+        Instantiates the activity and starts pre-loading the requested map.
+        """
+        import bsMap
+        Activity.__init__(self, settings)
+
+        # set some defaults..
+        self._allowPausing = True
+        self._allowKickIdlePlayers = True
+        self._spawnSound = bs.getSound('spawn')
+        self._showKillPoints = True  # whether to show points for kills
+
+        # go ahead and get our map loading..
+        if 'map' in settings:
+            mapName = settings['map']
+        else:
+            # if settings doesn't specify a map, pick a random one from the
+            # list of supported ones
+            unOwnedMaps = bsMap._getUnOwnedMaps()
+            validMaps = [m
+                         for m in self.getSupportedMaps(
+                             type(self.getSession())) if m not in unOwnedMaps]
+            if len(validMaps) == 0:
+                bs.screenMessage(bs.Lstr(resource='noValidMapsErrorText'))
+                raise Exception("No valid maps")
+            mapName = validMaps[random.randrange(len(validMaps))]
+
+        self._mapType = bsMap.getMapClass(mapName)
+        self._mapType.preload()
+        self._map = None
+
+    # fixme - should we expose this through the player class?...
+    def _getPlayerNode(self, player):
+        return player.gameData['_playerNode'].node
+
+    def getMap(self):
+        """
+        Returns the bs.Map in use for this activity.
+        """
+        if self._map is None:
+            raise Exception(
+                "getMap() cannot be called until after onTransitionIn()")
+        return self._map
+
+    def getInstanceDisplayString(self):
+        """
+        Returns a name for this particular game instance.
+        """
+        return self.getDisplayString(self.settings)
+
+    def getInstanceScoreBoardDisplayString(self):
+        """
+        Returns a name for this particular game instance, in English.
+        This name is used above the game scoreboard in the corner
+        of the screen, so it should be as concise as possible.
+        """
+        # if we're in a co-op session, use the level name
+        # FIXME; should clean this up..
+        try:
+            import bsUI
+            if isinstance(self.getSession(), bs.CoopSession):
+                return self.getSession()._campaign.getLevel(
+                    self.getSession()._campaignInfo['level']).getDisplayString()
+        except Exception:
+            bs.printError('error geting campaign level name')
+        return self.getInstanceDisplayString()
+
+    def getInstanceDescription(self):
+        """
+        Returns a description for this particular game instance, in English.
+        This is shown in the center of the screen below the game name at the
+        start of a game. It should start with a capital letter and end with a
+        period, and can be a bit more verbose than the version returned by
+        getInstanceScoreBoardDescription().
+
+        Note that translation is applied by looking up the specific returned
+        value as a key, so the number of returned variations should be limited;
+        ideally just one or two. To include arbitrary values in the description,
+        you can return a sequence of values in the following form instead of
+        just a string:
+
+        # this will give us something like 'Score 3 goals.' in English
+        # and can properly translate to 'Anota 3 goles.' in Spanish.
+        # If we just returned the string 'Score 3 Goals' here, there would
+        # have to be a translation entry for each specific number. ew.
+        return ['Score ${ARG1} goals.', self.settings['Score to Win']]
+
+        This way the first string can be consistently translated, with any arg 
+        values then substituted into the result. ${ARG1} will be replaced with
+        the first value, ${ARG2} with the second, etc.
+        """
+        return self.getDescription(type(self.getSession()))
+
+    def getInstanceScoreBoardDescription(self):
+        """
+        Returns a short description for this particular game instance, in
+        English. This description is used above the game scoreboard in the
+        corner of the screen, so it should be as concise as possible.
+        It should be lowercase and should not contain periods or other
+        punctuation.
+
+        Note that translation is applied by looking up the specific returned
+        value as a key, so the number of returned variations should be limited;
+        ideally just one or two. To include arbitrary values in the description,
+        you can return a sequence of values in the following form instead of
+        just a string:
+
+        # this will give us something like 'score 3 goals' in English
+        # and can properly translate to 'anota 3 goles' in Spanish.
+        # If we just returned the string 'score 3 goals' here, there would
+        # have to be a translation entry for each specific number. ew.
+        return ['score ${ARG1} goals', self.settings['Score to Win']]
+
+        This way the first string can be consistently translated, with any arg
+        values then substituted into the result. ${ARG1} will be replaced
+        with the first value, ${ARG2} with the second, etc.
+
+        """
+        return ''
+
+    def onTransitionIn(self, music=None):
+        """
+        Method override; optionally can
+        be passed a 'music' string which is the suggested type of
+        music to play during the game.
+        Note that in some cases music may be overridden by
+        the map or other factors, which is why you should pass
+        it in here instead of simply playing it yourself.
+        """
+
+        Activity.onTransitionIn(self)
+
+        # make our map
+        self._map = self._mapType()
+
+        # give our map a chance to override the music
+        # (for happy-thoughts and other such themed maps)
+        overrideMusic = self._mapType.getMusicType()
+        if overrideMusic is not None:
+            music = overrideMusic
+
+        if music is not None:
+            bs.playMusic(music)
+
+    def onBegin(self):
+        Activity.onBegin(self)
+
+        # report for analytics
+        s = self.getSession()
+        try:
+            if isinstance(s, bs.CoopSession):
+                import bsUI
+                bsInternal._setAnalyticsScreen(
+                    'Coop Game: '+s._campaign.getName()
+                    +' '+s._campaign.getLevel(bsUI.gCoopSessionArgs['level'])
+                    .getName())
+                bsInternal._incrementAnalyticsCount('Co-op round start')
+                if len(self.players) == 1:
+                    bsInternal._incrementAnalyticsCount(
+                        'Co-op round start 1 human player')
+                elif len(self.players) == 2:
+                    bsInternal._incrementAnalyticsCount(
+                        'Co-op round start 2 human players')
+                elif len(self.players) == 3:
+                    bsInternal._incrementAnalyticsCount(
+                        'Co-op round start 3 human players')
+                elif len(self.players) >= 4:
+                    bsInternal._incrementAnalyticsCount(
+                        'Co-op round start 4+ human players')
+            elif isinstance(s, bs.TeamsSession):
+                bsInternal._setAnalyticsScreen('Teams Game: '+self.getName())
+                bsInternal._incrementAnalyticsCount('Teams round start')
+                if len(self.players) == 1:
+                    bsInternal._incrementAnalyticsCount(
+                        'Teams round start 1 human player')
+                elif len(self.players) > 1 and len(self.players) < 8:
+                    bsInternal._incrementAnalyticsCount(
+                        'Teams round start ' + str(len(self.players)) +
+                        ' human players')
+                elif len(self.players) >= 8:
+                    bsInternal._incrementAnalyticsCount(
+                        'Teams round start 8+ human players')
+            elif isinstance(s, bs.FreeForAllSession):
+                bsInternal._setAnalyticsScreen(
+                    'FreeForAll Game: '+self.getName())
+                bsInternal._incrementAnalyticsCount('Free-for-all round start')
+                if len(self.players) == 1:
+                    bsInternal._incrementAnalyticsCount(
+                        'Free-for-all round start 1 human player')
+                elif len(self.players) > 1 and len(self.players) < 8:
+                    bsInternal._incrementAnalyticsCount(
+                        'Free-for-all round start ' + str(len(self.players)) +
+                        ' human players')
+                elif len(self.players) >= 8:
+                    bsInternal._incrementAnalyticsCount(
+                        'Free-for-all round start 8+ human players')
+        except Exception:
+            bs.printException("error setting analytics screen")
+
+        # for some analytics tracking on the c layer..
+        bsInternal._resetGameActivityTracking()
+
+        # we dont do this in onTransitionIn because it may depend on
+        # players/teams which arent available until now
+        bs.gameTimer(1, bs.WeakCall(self.showScoreBoardInfo))
+        bs.gameTimer(1000, bs.WeakCall(self.showInfo))
+        bs.gameTimer(2500, bs.WeakCall(self._showTip))
+
+        # store some basic info about players present at start time
+        self.initialPlayerInfo = [
+            {'name': p.getName(full=True),
+             'character': p.character} for p in self.players]
+
+        # sort this by name so high score lists/etc will be consistent
+        # regardless of player join order..
+        self.initialPlayerInfo.sort(key=lambda x: x['name'])
+
+        # if this is a tournament, query info about it such as how much
+        # time is left
+        try:
+            tournamentID = self.getSession()._tournamentID
+        except Exception:
+            tournamentID = None
+
+        if tournamentID is not None:
+            bsInternal._tournamentQuery(
+                args={'tournamentIDs': [tournamentID],
+                      'source': 'in-game time remaining query'},
+                callback=bs.WeakCall(self._onTournamentQueryResponse))
+
+    def _onTournamentQueryResponse(self, data):
+        import bsUI
+        if data is not None:
+            data = data['t']  # this used to be the whole payload
+            # keep our cached tourney info up to date
+            bsUI._cacheTournamentInfo(data)
+            self._setupTournamentTimeLimit(max(5, data[0]['timeRemaining']))
+
+    def onPlayerJoin(self, player):
+        Activity.onPlayerJoin(self, player)
+        # by default, just spawn a dude
+        self.spawnPlayer(player)
+
+    def onPlayerLeave(self, player):
+        Activity.onPlayerLeave(self, player)
+
+        # if the player has an actor, lets send it a die-message in a timer;
+        # not immediately. That way the player will no longer exist once the
+        # message goes through, making it easier for the game to realize the
+        # player has left and less likely to make a respawn timer or whatnot.
+        actor = player.actor
+        if actor is not None:
+            # use a strong ref (Call) to make sure the actor doesnt die here
+            # due to no refs
+            bs.gameTimer(0, bs.Call(actor.handleMessage,
+                                    bs.DieMessage(how='leftGame')))
+        player.setActor(None)
+
+    def handleMessage(self, msg):
+
+        if isinstance(msg, bs.PlayerSpazDeathMessage):
+
+            player = msg.spaz.getPlayer()
+            killer = msg.killerPlayer
+
+            # inform our score-set of the demise
+            self.scoreSet.playerLostSpaz(
+                player, killed=msg.killed, killer=killer)
+
+            # award the killer points if he's on a different team
+            if killer is not None and killer.getTeam() is not player.getTeam():
+                pts, importance = msg.spaz.getDeathPoints(msg.how)
+                if not self.hasEnded():
+                    self.scoreSet.playerScored(
+                        killer, pts, kill=True, victimPlayer=player,
+                        importance=importance, showPoints=self._showKillPoints)
+
+    def showScoreBoardInfo(self):
+        """
+        Creates the game info display
+        in the top left corner showing the name
+        and short description of the game.
+        """
+
+        sbName = self.getInstanceScoreBoardDisplayString()
+
+        # the description can be either a string or a sequence with args to swap
+        # in post-translation
+        sbDesc = self.getInstanceScoreBoardDescription()
+        if type(sbDesc) in [unicode, str]:
+            sbDesc = [sbDesc]  # handle simple string case
+        if type(sbDesc[0]) not in [unicode, str]:
+            raise Exception("Invalid format for instance description")
+
+        isEmpty = (sbDesc[0] == '')
+        subs = []
+        for i in range(len(sbDesc)-1):
+            subs.append(('${ARG'+str(i+1)+'}', str(sbDesc[i+1])))
+        translation = bs.Lstr(
+            translate=('gameDescriptions', sbDesc[0]),
+            subs=subs)
+        sbDesc = translation
+
+        vr = bs.getEnvironment()['vrMode']
+
+        # y = -34 if sbDesc == '' else -20
+        y = -34 if isEmpty else -20
+        y -= 16
+        self._gameScoreBoardNameText = bs.NodeActor(
+            bs.newNode(
+                "text",
+                attrs={'text': sbName, 'maxWidth': 300, 'position': (15, y)
+                       if
+                       isinstance(self.getSession(),
+                                  bs.FreeForAllSession) else(15, y),
+                       'hAttach': "left", 'vrDepth': 10, 'vAttach': "top",
+                       'vAlign': 'bottom', 'color': (1.0, 1.0, 1.0, 1.0),
+                       'shadow': 1.0 if vr else 0.6, 'flatness': 1.0
+                       if vr else 0.5, 'scale': 1.1}))
+
+        bsUtils.animate(self._gameScoreBoardNameText.node,
+                        'opacity', {0: 0.0, 1000: 1.0})
+
+        self._gameScoreBoardDescriptionText = bs.NodeActor(
+            bs.newNode(
+                "text",
+                attrs={'text': sbDesc, 'maxWidth': 480,
+                       'position': (17, -44 + 10)
+                       if
+                       isinstance(self.getSession(),
+                                  bs.FreeForAllSession) else(17, -44 + 10),
+                       'scale': 0.7, 'hAttach': "left", 'vAttach': "top",
+                       'vAlign': 'top', 'shadow': 1.0 if vr else 0.7,
+                       'flatness': 1.0 if vr else 0.8, 'color': (1, 1, 1, 1)
+                       if vr else(0.9, 0.9, 0.9, 1.0)}))
+
+        bsUtils.animate(self._gameScoreBoardDescriptionText.node,
+                        'opacity', {0: 0.0, 1000: 1.0})
+
+    def showInfo(self):
+        """ show the game description """
+        name = self.getInstanceDisplayString()
+        bsUtils.ZoomText(
+            name, maxWidth=800, lifespan=2500, jitter=2.0, position=(0, 180),
+            flash=False, color=(0.93 * 1.25, 0.9 * 1.25, 1.0 * 1.25),
+            trailColor=(0.15, 0.05, 1.0, 0.0)).autoRetain()
+        bs.gameTimer(200, bs.Call(bs.playSound, bs.getSound('gong')))
+
+        # the description can be either a string or a sequence with args to swap
+        # in post-translation
+        desc = self.getInstanceDescription()
+        if type(desc) in [unicode, str]:
+            desc = [desc]  # handle simple string case
+        if type(desc[0]) not in [unicode, str]:
+            raise Exception("Invalid format for instance description")
+        subs = []
+        for i in range(len(desc)-1):
+            subs.append(('${ARG'+str(i+1)+'}', str(desc[i+1])))
+        translation = bs.Lstr(
+            translate=('gameDescriptions', desc[0]),
+            subs=subs)
+
+        # do some standard filters (epic mode, etc)
+        if ('Epic Mode' in self.settings and self.settings['Epic Mode']):
+            translation = bs.Lstr(
+                resource='epicDescriptionFilterText',
+                subs=[('${DESCRIPTION}', translation)])
+
+        vr = bs.getEnvironment()['vrMode']
+
+        d = bs.newNode('text',
+                       attrs={'vAttach': 'center',
+                              'hAttach': 'center',
+                              'hAlign': 'center',
+                              'color': (1, 1, 1, 1),
+                              'shadow': 1.0 if vr else 0.5,
+                              'flatness': 1.0 if vr else 0.5,
+                              'vrDepth': -30,
+                              'position': (0, 80),
+                              'scale': 1.2,
+                              'maxWidth': 700,
+                              'text': translation})
+        c = bs.newNode(
+            "combine", owner=d,
+            attrs={'input0': 1.0, 'input1': 1.0, 'input2': 1.0, 'size': 4})
+        c.connectAttr('output', d, 'color')
+        keys = {500: 0, 1000: 1.0, 2500: 1.0, 4000: 0.0}
+        bsUtils.animate(c, "input3", keys)
+        bs.gameTimer(4000, d.delete)
+
+    def _showTip(self):
+
+        # if theres any tips left on the global list, display one..
+        if len(self.tips) > 0:
+            tip = self.tips.pop(random.randrange(len(self.tips)))
+            tipTitle = bs.Lstr(value='${A}:', subs=[
+                               ('${A}', bs.Lstr(resource='tipText'))])
+            icon = None
+            sound = None
+            if type(tip) == dict:
+                if 'icon' in tip:
+                    icon = tip['icon']
+                if 'sound' in tip:
+                    sound = tip['sound']
+                tip = tip['tip']
+
+            # a few subs..
+            tip = bs.Lstr(translate=('tips', tip), subs=[
+                          ('${PICKUP}', bs.getSpecialChar('topButton'))])
+            basePosition = (75, 50)
+            tipScale = 0.8
+            tipTitleScale = 1.2
+            vr = bs.getEnvironment()['vrMode']
+
+            tOffs = -350.0
+            t = bs.newNode(
+                'text',
+                attrs={'text': tip, 'scale': tipScale, 'maxWidth': 900,
+                       'position': (basePosition[0] + tOffs, basePosition[1]),
+                       'hAlign': 'left', 'vrDepth': 300, 'shadow': 1.0
+                       if vr else 0.5, 'flatness': 1.0 if vr else 0.5,
+                       'vAlign': 'center', 'vAttach': 'bottom'})
+            t2 = bs.newNode(
+                'text', owner=t,
+                attrs={'text': tipTitle, 'scale': tipTitleScale,
+                       'position':
+                       (basePosition[0] + tOffs -
+                        (20 if icon is None else 82),
+                        basePosition[1] + 2),
+                       'hAlign': 'right', 'vrDepth': 300, 'shadow': 1.0
+                       if vr else 0.5, 'flatness': 1.0 if vr else 0.5,
+                       'maxWidth': 140, 'vAlign': 'center',
+                       'vAttach': 'bottom'})
+            if icon is not None:
+                img = bs.newNode('image',
+                                 attrs={'texture': icon,
+                                        'position': (basePosition[0]+tOffs-40,
+                                                     basePosition[1]+1),
+                                        'scale': (50, 50),
+                                        'opacity': 1.0,
+                                        'vrDepth': 315,
+                                        'color': (1, 1, 1),
+                                        'absoluteScale': True,
+                                        'attach': 'bottomCenter'})
+                bsUtils.animate(img, 'opacity', {
+                                0: 0, 1000: 1, 4000: 1, 5000: 0})
+                bs.gameTimer(5000, img.delete)
+            if sound is not None:
+                bs.playSound(sound)
+
+            c = bs.newNode(
+                "combine", owner=t,
+                attrs={'input0': 1.0, 'input1': 0.8, 'input2': 1.0, 'size': 4})
+            c.connectAttr('output', t, 'color')
+            c.connectAttr('output', t2, 'color')
+            bsUtils.animate(c, 'input3', {0: 0, 1000: 1, 4000: 1, 5000: 0})
+            bs.gameTimer(5000, t.delete)
+
+    def endGame(self):
+        """
+        Tells the game to wrap itself up and call bs.Activity.end() immediately.
+        This method should be overridden by subclasses.
+
+        A game should always be prepared to end and deliver results, even if
+        there is no 'winner' yet; this way things like the standard time-limit
+        (bs.GameActivity.setupStandardTimeLimit()) will work with the game.
+        """
+        print ('WARNING: default endGame() implementation called;'
+               ' your game should override this.')
+
+    def onContinue(self):
+        pass
+
+    def spawnPlayerIfExists(self, player):
+        """
+        A utility method which calls self.spawnPlayer() *only* if the bs.Player
+        provided still exists; handy for use in timers and whatnot.
+
+        There is no need to override this; just override spawnPlayer().
+        """
+        if player.exists():
+            self.spawnPlayer(player)
+
+    def spawnPlayer(self, player):
+        """
+        Spawn *something* for the provided bs.Player.
+        The default implementation simply calls spawnPlayerSpaz().
+        """
+
+        if not player.exists():
+            bs.printError('spawnPlayer() called for nonexistant player')
+            return
+
+        return self.spawnPlayerSpaz(player)
+
+    def respawnPlayer(self, player, respawnTime=None):
+        """
+        Given a bs.Player, sets up a standard respawn timer,
+        along with the standard counter display, etc.
+        At the end of the respawn period spawnPlayer() will
+        be called if the Player still exists.
+        An explicit 'respawnTime' can optionally be provided
+        (in milliseconds).
+        """
+
+        if player is None or not player.exists():
+            if player is None:
+                bs.printError('None passed as player to respawnPlayer()')
+            else:
+                bs.printError('Nonexistant bs.Player passed to respawnPlayer();'
+                              ' call player.exists() to make sure a player'
+                              ' is still there.')
+            return
+
+        if player.getTeam() is None:
+            bs.printError('player has no team in respawnPlayer()')
+            return
+
+        if respawnTime is None:
+            if len(player.getTeam().players) == 1:
+                respawnTime = 3000
+            elif len(player.getTeam().players) == 2:
+                respawnTime = 5000
+            elif len(player.getTeam().players) == 3:
+                respawnTime = 6000
+            else:
+                respawnTime = 7000
+
+        # if this standard setting is present, factor it in
+        if 'Respawn Times' in self.settings:
+            respawnTime *= self.settings['Respawn Times']
+
+        respawnTime = int(max(1000, respawnTime))
+        if respawnTime % 1000 != 0:
+            respawnTime -= respawnTime % 1000  # we want whole seconds
+
+        if player.actor and not self.hasEnded():
+            import bsSpaz
+            player.gameData['respawnTimer'] = bs.Timer(
+                respawnTime, bs.WeakCall(self.spawnPlayerIfExists, player))
+            player.gameData['respawnIcon'] = bsSpaz.RespawnIcon(
+                player, respawnTime)
+
+    def spawnPlayerSpaz(self, player, position=(0, 0, 0), angle=None):
+        """
+        Create and wire up a bs.PlayerSpaz for the provide bs.Player.
+        """
+        name = player.getName()
+        color = player.color
+        highlight = player.highlight
+
+        lightColor = bsUtils.getNormalizedColor(color)
+        displayColor = bs.getSafeColor(color, targetIntensity=0.75)
+        spaz = bs.PlayerSpaz(color=color,
+                             highlight=highlight,
+                             character=player.character,
+                             player=player)
+        player.setActor(spaz)
+
+        # if this is co-op and we're on Courtyard or Runaround, add the
+        # material that allows us to collide with the player-walls
+        # FIXME; need to generalize this
+        if isinstance(
+                self.getSession(),
+                bs.CoopSession) and self.getMap().getName() in[
+                'Courtyard', 'Tower D']:
+            mat = self.getMap().preloadData['collideWithWallMaterial']
+            spaz.node.materials += (mat,)
+            spaz.node.rollerMaterials += (mat,)
+
+        spaz.node.name = name
+        spaz.node.nameColor = displayColor
+        spaz.connectControlsToPlayer()
+        self.scoreSet.playerGotNewSpaz(player, spaz)
+
+        # move to the stand position and add a flash of light
+        spaz.handleMessage(
+            bs.StandMessage(
+                position, angle
+                if angle is not None else random.uniform(0, 360)))
+        t = bs.getGameTime()
+        bs.playSound(self._spawnSound, 1, position=spaz.node.position)
+        light = bs.newNode('light', attrs={'color': lightColor})
+        spaz.node.connectAttr('position', light, 'position')
+        bsUtils.animate(light, 'intensity', {0: 0, 250: 1, 500: 0})
+        bs.gameTimer(500, light.delete)
+        return spaz
+
+    def projectFlagStand(self, pos):
+        """
+        Projects a flag-stand onto the ground at the given position.
+        Useful for games such as capture-the-flag to show where a
+        movable flag originated from.
+        """
+        # need to do this in a timer for it to work.. need to look into that.
+        bs.gameTimer(1, bs.WeakCall(self._projectFlagStand, pos[:3]))
+
+    def _projectFlagStand(self, pos):
+        bs.emitBGDynamics(position=pos, emitType='flagStand')
+
+    def setupStandardPowerupDrops(self, enableTNT=True):
+        """
+        Create standard powerup drops for the current map.
+        """
+        import bsPowerup
+        self._powerupDropTimer = bs.Timer(
+            bsPowerup.defaultPowerupInterval, bs.WeakCall(
+                self._standardDropPowerups),
+            repeat=True)
+        self._standardDropPowerups()
+        if enableTNT:
+            self._tntObjs = {}
+            self._tntDropTimer = bs.Timer(
+                5500, bs.WeakCall(self._standardDropTnt),
+                repeat=True)
+            self._standardDropTnt()
+
+    def _standardDropPowerup(self, index, expire=True):
+        import bsPowerup
+        bsPowerup.Powerup(
+            position=self.getMap().powerupSpawnPoints[index],
+            powerupType=bs.Powerup.getFactory().getRandomPowerupType(),
+            expire=expire).autoRetain()
+
+    def _standardDropPowerups(self):
+        """
+        Standard powerup drop.
+        """
+        # drop one powerup per point
+        pts = self.getMap().powerupSpawnPoints
+        for i, pt in enumerate(pts):
+            bs.gameTimer(i*400, bs.WeakCall(self._standardDropPowerup, i))
+
+    def _standardDropTnt(self):
+        """
+        Standard tnt drop.
+        """
+        # drop tnt on the map for any tnt location with no existing tnt box
+        for i, pt in enumerate(self.getMap().tntPoints):
+            if not i in self._tntObjs:
+                self._tntObjs[i] = {'absentTicks': 9999, 'obj': None}
+            tntObj = self._tntObjs[i]
+
+            # respawn once its been dead for a while..
+            if tntObj['obj'] is None or not tntObj['obj'].exists():
+                tntObj['absentTicks'] += 1
+                if tntObj['absentTicks'] > 3:
+                    tntObj['obj'] = bs.Bomb(position=pt, bombType='tnt')
+                    tntObj['absentTicks'] = 0
+
+    def setupStandardTimeLimit(self, duration):
+        """
+        Create a standard game time-limit given the provided
+        duration in seconds.
+        This will be displayed at the top of the screen.
+        If the time-limit expires, endGame() will be called.
+        """
+
+        if duration <= 0:
+            return
+        self._standardTimeLimitTime = int(duration)
+        self._standardTimeLimitTimer = bs.Timer(
+            1000, bs.WeakCall(self._standardTimeLimitTick), repeat=True)
+        self._standardTimeLimitText = bs.NodeActor(
+            bs.newNode(
+                'text',
+                attrs={'vAttach': 'top', 'hAttach': 'center', 'hAlign': 'left',
+                       'color': (1.0, 1.0, 1.0, 0.5),
+                       'position': (-25, -30),
+                       'flatness': 1.0, 'scale': 0.9}))
+        self._standardTimeLimitTextInput = bs.NodeActor(bs.newNode(
+            'timeDisplay', attrs={'time2': duration*1000, 'timeMin': 0}))
+        bs.getSharedObject('globals').connectAttr(
+            'gameTime', self._standardTimeLimitTextInput.node, 'time1')
+        self._standardTimeLimitTextInput.node.connectAttr(
+            'output', self._standardTimeLimitText.node, 'text')
+
+    def _standardTimeLimitTick(self):
+        self._standardTimeLimitTime -= 1
+        if self._standardTimeLimitTime <= 10:
+            if self._standardTimeLimitTime == 10:
+                self._standardTimeLimitText.node.scale = 1.3
+                self._standardTimeLimitText.node.position = (-30, -45)
+                c = bs.newNode(
+                    'combine', owner=self._standardTimeLimitText.node,
+                    attrs={'size': 4})
+                c.connectAttr(
+                    'output', self._standardTimeLimitText.node, 'color')
+                bsUtils.animate(c, "input0", {0: 1, 150: 1}, loop=True)
+                bsUtils.animate(c, "input1", {0: 1, 150: 0.5}, loop=True)
+                bsUtils.animate(c, "input2", {0: 0.1, 150: 0.0}, loop=True)
+                c.input3 = 1.0
+            bs.playSound(bs.getSound('tick'))
+        if self._standardTimeLimitTime <= 0:
+            self._standardTimeLimitTimer = None
+            self.endGame()
+            n = bs.newNode('text',
+                           attrs={'vAttach': 'top', 'hAttach': 'center',
+                                  'hAlign': 'center', 'color': (1, 0.7, 0, 1),
+                                  'position': (0, -90), 'scale': 1.2,
+                                  'text': bs.Lstr(resource='timeExpiredText')
+                                  })
+            bs.playSound(bs.getSound('refWhistle'))
+            bsUtils.animate(n, "scale", {0: 0.0, 100: 1.4, 150: 1.2})
+
+    def _setupTournamentTimeLimit(self, duration):
+        """
+        Create a tournament game time-limit given the provided
+        duration in seconds.
+        This will be displayed at the top of the screen.
+        If the time-limit expires, endGame() will be called.
+        """
+
+        if duration <= 0:
+            return
+        self._tournamentTimeLimitTime = int(duration)
+        # we need this timer to match the server's time as close as possible,
+        # so lets go with net-time.. theoretically we should do real-time but
+        # then we have to mess with contexts and whatnot... :-/
+        self._tournamentTimeLimitTimer = bs.Timer(
+            1000, bs.WeakCall(self._tournamentTimeLimitTick),
+            repeat=True, timeType='net')
+        self._tournamentTimeLimitTitleText = bs.NodeActor(
+            bs.newNode('text', attrs={
+                'vAttach': 'bottom',
+                'hAttach': 'left',
+                'hAlign': 'center',
+                'vAlign': 'center',
+                'vrDepth': 300,
+                'maxWidth': 100,
+                'color': (1.0, 1.0, 1.0, 0.5),
+                'position': (60, 50),
+                'flatness': 1.0,
+                'scale': 0.5,
+                'text': bs.Lstr(resource='tournamentText')
+            }))
+        self._tournamentTimeLimitText = bs.NodeActor(bs.newNode('text', attrs={
+            'vAttach': 'bottom',
+            'hAttach': 'left',
+            'hAlign': 'center',
+            'vAlign': 'center',
+            'vrDepth': 300,
+            'maxWidth': 100,
+            'color': (1.0, 1.0, 1.0, 0.5),
+            'position': (60, 30),
+            'flatness': 1.0,
+            'scale': 0.9}))
+        self._tournamentTimeLimitTextInput = bs.NodeActor(
+            bs.newNode('timeDisplay', attrs={
+                'timeMin': 0,
+                'time2': self._tournamentTimeLimitTime*1000}))
+        self._tournamentTimeLimitTextInput.node.connectAttr(
+            'output', self._tournamentTimeLimitText.node, 'text')
+
+    def _tournamentTimeLimitTick(self):
+        self._tournamentTimeLimitTime -= 1
+        if self._tournamentTimeLimitTime <= 10:
+            if self._tournamentTimeLimitTime == 10:
+                self._tournamentTimeLimitTitleText.node.scale = 1.0
+                self._tournamentTimeLimitText.node.scale = 1.3
+                self._tournamentTimeLimitTitleText.node.position = (80, 85)
+                self._tournamentTimeLimitText.node.position = (80, 60)
+                c = bs.newNode(
+                    'combine', owner=self._tournamentTimeLimitText.node,
+                    attrs={'size': 4})
+                c.connectAttr(
+                    'output', self._tournamentTimeLimitTitleText.node, 'color')
+                c.connectAttr(
+                    'output', self._tournamentTimeLimitText.node, 'color')
+                bsUtils.animate(c, "input0", {0: 1, 150: 1}, loop=True)
+                bsUtils.animate(c, "input1", {0: 1, 150: 0.5}, loop=True)
+                bsUtils.animate(c, "input2", {0: 0.1, 150: 0.0}, loop=True)
+                c.input3 = 1.0
+            bs.playSound(bs.getSound('tick'))
+        if self._tournamentTimeLimitTime <= 0:
+            self._tournamentTimeLimitTimer = None
+            self.endGame()
+            n = bs.newNode(
+                'text',
+                attrs={'vAttach': 'top', 'hAttach': 'center',
+                       'hAlign': 'center', 'color': (1, 0.7, 0, 1),
+                       'position': (0, -200),
+                       'scale': 1.6, 'text': bs.Lstr(
+                           resource='tournamentTimeExpiredText',
+                           fallbackResource='timeExpiredText')})
+            bs.playSound(bs.getSound('refWhistle'))
+            bsUtils.animate(n, "scale", {0: 0.0, 100: 1.4, 150: 1.2})
+
+        # normally we just connect this to time, but since this is a bit of a
+        # funky setup we just update it manually once per second..
+        self._tournamentTimeLimitTextInput.node.time2 = \
+            self._tournamentTimeLimitTime*1000
+
+    def showZoomMessage(
+            self, message, color=(0.9, 0.4, 0.0),
+            scale=0.8, duration=2000, trail=False):
+        """
+        Show the standard zooming text used to announce
+        game names and winners.
+        """
+        # reserve a spot on the screen (in case we get multiple of these so
+        # they dont overlap)
+        try:
+            times = self._zoomMessageTimes
+        except Exception:
+            self._zoomMessageTimes = {}
+        i = 0
+        curTime = bs.getGameTime()
+        while True:
+            if (i not in self._zoomMessageTimes
+                    or self._zoomMessageTimes[i] < curTime):
+                self._zoomMessageTimes[i] = curTime + duration
+                break
+            i += 1
+        bsUtils.ZoomText(message, lifespan=duration, jitter=2.0,
+                         position=(0, 200-i*100), scale=scale, maxWidth=800,
+                         trail=trail, color=color).autoRetain()
+
+    def cameraFlash(self, duration=999):
+        """
+        Create a strobing camera flash effect
+        as seen when a team wins a game.
+        """
+        xSpread = 10
+        ySpread = 5
+        positions = [
+            [-xSpread, -ySpread],
+            [0, -ySpread],
+            [0, ySpread],
+            [xSpread, -ySpread],
+            [xSpread, ySpread],
+            [-xSpread, ySpread]]
+        times = [0, 2700, 1000, 1800, 500, 1400]
+
+        # store this on our activity so we only have one at a time
+        self._cameraFlash = []
+        for i in range(6):
+            light = bs.NodeActor(bs.newNode("light", attrs={
+                'position': (positions[i][0], 0, positions[i][1]),
+                'radius': 1.0,
+                'lightVolumes': False,
+                'heightAttenuated': False,
+                'color': (0.2, 0.2, 0.8)}))
+            s = 1.87
+            iScale = 1.3
+            tcombine = bs.newNode(
+                "combine", owner=light.node,
+                attrs={'size': 3, 'input0': positions[i][0],
+                       'input1': 0, 'input2': positions[i][1]})
+            tcombine.connectAttr('output', light.node, 'position')
+            x = positions[i][0]
+            y = positions[i][1]
+            spd = 0.5 + random.random()
+            spd2 = 0.5 + random.random()
+            bsUtils.animate(tcombine, 'input0',
+                            {0: x+0, 69*spd: x+10.0,
+                             143*spd: x-10.0, 201*spd: x+0},
+                            loop=True)
+            bsUtils.animate(tcombine, 'input2',
+                            {0: y+0, 150*spd2: y+10.0,
+                             287*spd2: y-10.0, 398*spd2: y+0},
+                            loop=True)
+            bsUtils.animate(
+                light.node, "intensity",
+                {0: 0, 20 * s: 0, 50 * s: 0.8 * iScale, 80 * s: 0, 100 * s: 0},
+                loop=True, offset=times[i])
+            bs.gameTimer(
+                int(times[i] + random.randint(1, duration) * 40 * s),
+                light.node.delete)
+            self._cameraFlash.append(light)
diff --git a/scripts/bsSpaz.py b/scripts/bsSpaz.py
new file mode 100644
index 0000000000000000000000000000000000000000..3accc304a061d133d938bf0d3040402960c19d2f
--- /dev/null
+++ b/scripts/bsSpaz.py
@@ -0,0 +1,3890 @@
+import weakref
+import random
+
+import bs
+import bsUtils
+import bsInternal
+
+# list of defined spazzes
+appearances = {}
+
+
+def getAppearances(includeLocked=False):
+    disallowed = []
+    if not includeLocked:
+        # hmm yeah this'll be tough to hack...
+        if not bsInternal._getPurchased('characters.santa'):
+            disallowed.append('Santa Claus')
+        if not bsInternal._getPurchased('characters.frosty'):
+            disallowed.append('Frosty')
+        if not bsInternal._getPurchased('characters.bones'):
+            disallowed.append('Bones')
+        if not bsInternal._getPurchased('characters.bernard'):
+            disallowed.append('Bernard')
+        if not bsInternal._getPurchased('characters.pixie'):
+            disallowed.append('Pixel')
+        if not bsInternal._getPurchased('characters.pascal'):
+            disallowed.append('Pascal')
+        if not bsInternal._getPurchased('characters.actionhero'):
+            disallowed.append('Todd McBurton')
+        if not bsInternal._getPurchased('characters.taobaomascot'):
+            disallowed.append('Taobao Mascot')
+        if not bsInternal._getPurchased('characters.agent'):
+            disallowed.append('Agent Johnson')
+        if not bsInternal._getPurchased('characters.jumpsuit'):
+            disallowed.append('Lee')
+        if not bsInternal._getPurchased('characters.assassin'):
+            disallowed.append('Zola')
+        if not bsInternal._getPurchased('characters.wizard'):
+            disallowed.append('Grumbledorf')
+        if not bsInternal._getPurchased('characters.cowboy'):
+            disallowed.append('Butch')
+        if not bsInternal._getPurchased('characters.witch'):
+            disallowed.append('Witch')
+        if not bsInternal._getPurchased('characters.warrior'):
+            disallowed.append('Warrior')
+        if not bsInternal._getPurchased('characters.superhero'):
+            disallowed.append('Middle-Man')
+        if not bsInternal._getPurchased('characters.alien'):
+            disallowed.append('Alien')
+        if not bsInternal._getPurchased('characters.oldlady'):
+            disallowed.append('OldLady')
+        if not bsInternal._getPurchased('characters.gladiator'):
+            disallowed.append('Gladiator')
+        if not bsInternal._getPurchased('characters.wrestler'):
+            disallowed.append('Wrestler')
+        if not bsInternal._getPurchased('characters.operasinger'):
+            disallowed.append('Gretel')
+        if not bsInternal._getPurchased('characters.robot'):
+            disallowed.append('Robot')
+        if not bsInternal._getPurchased('characters.cyborg'):
+            disallowed.append('B-9000')
+        if not bsInternal._getPurchased('characters.bunny'):
+            disallowed.append('Easter Bunny')
+        if not bsInternal._getPurchased('characters.kronk'):
+            disallowed.append('Kronk')
+        if not bsInternal._getPurchased('characters.zoe'):
+            disallowed.append('Zoe')
+        if not bsInternal._getPurchased('characters.jackmorgan'):
+            disallowed.append('Jack Morgan')
+        if not bsInternal._getPurchased('characters.mel'):
+            disallowed.append('Mel')
+        if not bsInternal._getPurchased('characters.snakeshadow'):
+            disallowed.append('Snake Shadow')
+    return [s for s in appearances.keys() if s not in disallowed]
+
+
+gPowerupWearOffTime = 20000
+gBasePunchPowerScale = 1.2 # obsolete - just used for demo guy now
+gBasePunchCooldown = 400
+gLameBotColor = (1.2, 0.9, 0.2)
+gLameBotHighlight = (1.0, 0.5, 0.6)
+gDefaultBotColor = (0.6, 0.6, 0.6)
+gDefaultBotHighlight = (0.1, 0.3, 0.1)
+gProBotColor = (1.0, 0.2, 0.1)
+gProBotHighlight = (0.6, 0.1, 0.05)
+gLastTurboSpamWarningTime = -99999
+
+
+class _PickupMessage(object):
+    'We wanna pick something up'
+    pass
+
+
+class _PunchHitMessage(object):
+    'Message saying an object was hit'
+    pass
+
+
+class _CurseExplodeMessage(object):
+    'We are cursed and should blow up now.'
+    pass
+
+
+class _BombDiedMessage(object):
+    "A bomb has died and thus can be recycled"
+    pass
+
+
+class Appearance(object):
+    """Create and fill out one of these suckers to define a spaz appearance"""
+    def __init__(self, name):
+        self.name = name
+        if appearances.has_key(self.name):
+            raise Exception("spaz appearance name \""
+                            + self.name + "\" already exists.")
+        appearances[self.name] = self
+        self.colorTexture = ""
+        self.headModel = ""
+        self.torsoModel = ""
+        self.pelvisModel = ""
+        self.upperArmModel = ""
+        self.foreArmModel = ""
+        self.handModel = ""
+        self.upperLegModel = ""
+        self.lowerLegModel = ""
+        self.toesModel = ""
+        self.jumpSounds = []
+        self.attackSounds = []
+        self.impactSounds = []
+        self.deathSounds = []
+        self.pickupSounds = []
+        self.fallSounds = []
+        self.style = 'spaz'
+        self.defaultColor = None
+        self.defaultHighlight = None
+
+
+class SpazFactory(object):
+    """
+    Category: Game Flow Classes
+
+    Wraps up media and other resources used by bs.Spaz instances.
+    Generally one of these is created per bs.Activity and shared
+    between all spaz instances.  Use bs.Spaz.getFactory() to return
+    the shared factory for the current activity.
+
+    Attributes:
+
+       impactSoundsMedium
+          A tuple of bs.Sounds for when a bs.Spaz hits something kinda hard.
+
+       impactSoundsHard
+          A tuple of bs.Sounds for when a bs.Spaz hits something really hard.
+
+       impactSoundsHarder
+          A tuple of bs.Sounds for when a bs.Spaz hits something really
+          really hard.
+
+       singlePlayerDeathSound
+          The sound that plays for an 'importan' spaz death such as in
+          co-op games.
+
+       punchSound
+          A standard punch bs.Sound.
+       
+       punchSoundsStrong
+          A tuple of stronger sounding punch bs.Sounds.
+
+       punchSoundStronger
+          A really really strong sounding punch bs.Sound.
+
+       swishSound
+          A punch swish bs.Sound.
+
+       blockSound
+          A bs.Sound for when an attack is blocked by invincibility.
+
+       shatterSound
+          A bs.Sound for when a frozen bs.Spaz shatters.
+
+       splatterSound
+          A bs.Sound for when a bs.Spaz blows up via curse.
+
+       spazMaterial
+          A bs.Material applied to all of parts of a bs.Spaz.
+
+       rollerMaterial
+          A bs.Material applied to the invisible roller ball body that a bs.Spaz
+          uses for locomotion.
+    
+       punchMaterial
+          A bs.Material applied to the 'fist' of a bs.Spaz.
+
+       pickupMaterial
+          A bs.Material applied to the 'grabber' body of a bs.Spaz.
+
+       curseMaterial
+          A bs.Material applied to a cursed bs.Spaz that triggers an explosion.
+    """
+
+    def _preload(self, character):
+        """
+        Preload media that will be needed for a given character.
+        """
+        self._getMedia(character)
+
+    def __init__(self):
+        """
+        Instantiate a factory object.
+        """
+
+        self.impactSoundsMedium = (bs.getSound('impactMedium'),
+                                bs.getSound('impactMedium2'))
+        self.impactSoundsHard = (bs.getSound('impactHard'),
+                                 bs.getSound('impactHard2'),
+                                 bs.getSound('impactHard3'))
+        self.impactSoundsHarder = (bs.getSound('bigImpact'),
+                                   bs.getSound('bigImpact2'))
+        self.singlePlayerDeathSound = bs.getSound('playerDeath')
+        self.punchSound = bs.getSound('punch01')
+        
+        self.punchSoundsStrong = (bs.getSound('punchStrong01'),
+                                  bs.getSound('punchStrong02'))
+        
+        self.punchSoundStronger = bs.getSound('superPunch')
+        
+        self.swishSound = bs.getSound('punchSwish')
+        self.blockSound = bs.getSound('block')
+        self.shatterSound = bs.getSound('shatter')
+        self.splatterSound = bs.getSound('splatter')
+        
+        self.spazMaterial = bs.Material()
+        self.rollerMaterial = bs.Material()
+        self.punchMaterial = bs.Material()
+        self.pickupMaterial = bs.Material()
+        self.curseMaterial = bs.Material()
+
+        footingMaterial = bs.getSharedObject('footingMaterial')
+        objectMaterial = bs.getSharedObject('objectMaterial')
+        playerMaterial = bs.getSharedObject('playerMaterial')
+        regionMaterial = bs.getSharedObject('regionMaterial')
+        
+        # send footing messages to spazzes so they know when they're on solid
+        # ground.
+        # eww this should really just be built into the spaz node
+        self.rollerMaterial.addActions(
+            conditions=('theyHaveMaterial', footingMaterial),
+            actions=(('message', 'ourNode', 'atConnect', 'footing', 1),
+                     ('message', 'ourNode', 'atDisconnect', 'footing', -1)))
+
+        self.spazMaterial.addActions(
+            conditions=('theyHaveMaterial', footingMaterial),
+            actions=(('message', 'ourNode', 'atConnect', 'footing', 1),
+                     ('message', 'ourNode', 'atDisconnect', 'footing', -1)))
+        # punches
+        self.punchMaterial.addActions(
+            conditions=('theyAreDifferentNodeThanUs',),
+            actions=(('modifyPartCollision', 'collide', True),
+                     ('modifyPartCollision', 'physical', False),
+                     ('message', 'ourNode', 'atConnect', _PunchHitMessage())))
+        # pickups
+        self.pickupMaterial.addActions(
+            conditions=(('theyAreDifferentNodeThanUs',),
+                        'and', ('theyHaveMaterial', objectMaterial)),
+            actions=(('modifyPartCollision', 'collide', True),
+                     ('modifyPartCollision', 'physical', False),
+                     ('message', 'ourNode', 'atConnect', _PickupMessage())))
+        # curse
+        self.curseMaterial.addActions(
+            conditions=(('theyAreDifferentNodeThanUs',),
+                        'and', ('theyHaveMaterial', playerMaterial)),
+            actions=('message', 'ourNode', 'atConnect', _CurseExplodeMessage()))
+
+        self.footImpactSounds = (bs.getSound('footImpact01'),
+                                 bs.getSound('footImpact02'),
+                                 bs.getSound('footImpact03'))
+
+        self.footSkidSound = bs.getSound('skid01')
+        self.footRollSound = bs.getSound('scamper01')
+
+        self.rollerMaterial.addActions(
+            conditions=('theyHaveMaterial', footingMaterial),
+            actions=(('impactSound', self.footImpactSounds, 1, 0.2),
+                     ('skidSound', self.footSkidSound, 20, 0.3),
+                     ('rollSound', self.footRollSound, 20, 3.0)))
+
+        self.skidSound = bs.getSound('gravelSkid')
+
+        self.spazMaterial.addActions(
+            conditions=('theyHaveMaterial', footingMaterial),
+            actions=(('impactSound', self.footImpactSounds, 20, 6),
+                     ('skidSound', self.skidSound, 2.0, 1),
+                     ('rollSound', self.skidSound, 2.0, 1)))
+
+        self.shieldUpSound = bs.getSound('shieldUp')
+        self.shieldDownSound = bs.getSound('shieldDown')
+        self.shieldHitSound = bs.getSound('shieldHit')
+
+        # we dont want to collide with stuff we're initially overlapping
+        # (unless its marked with a special region material)
+        self.spazMaterial.addActions(
+            conditions=((('weAreYoungerThan', 51),
+                         'and', ('theyAreDifferentNodeThanUs',)),
+                        'and', ('theyDontHaveMaterial', regionMaterial)),
+            actions=(('modifyNodeCollision', 'collide', False)))
+        
+        self.spazMedia = {}
+
+        # lets load some basic rules (allows them to be tweaked from the
+        # master server)
+        self.shieldDecayRate = bsInternal._getAccountMiscReadVal('rsdr', 10.0)
+        self.punchCooldown = bsInternal._getAccountMiscReadVal('rpc', 400)
+        self.punchCooldownGloves = \
+            bsInternal._getAccountMiscReadVal('rpcg', 300)
+        self.punchPowerScale = bsInternal._getAccountMiscReadVal('rpp', 1.2)
+        self.punchPowerScaleGloves = \
+            bsInternal._getAccountMiscReadVal('rppg', 1.4)
+        self.maxShieldSpilloverDamage = \
+            bsInternal._getAccountMiscReadVal('rsms', 500)
+
+    def _getStyle(self, character):
+        return appearances[character].style
+        
+    def _getMedia(self, character):
+        t = appearances[character]
+        if not self.spazMedia.has_key(character):
+            m = self.spazMedia[character] = {
+                'jumpSounds':[bs.getSound(s) for s in t.jumpSounds],
+                'attackSounds':[bs.getSound(s) for s in t.attackSounds],
+                'impactSounds':[bs.getSound(s) for s in t.impactSounds],
+                'deathSounds':[bs.getSound(s) for s in t.deathSounds],
+                'pickupSounds':[bs.getSound(s) for s in t.pickupSounds],
+                'fallSounds':[bs.getSound(s) for s in t.fallSounds],
+                'colorTexture':bs.getTexture(t.colorTexture),
+                'colorMaskTexture':bs.getTexture(t.colorMaskTexture),
+                'headModel':bs.getModel(t.headModel),
+                'torsoModel':bs.getModel(t.torsoModel),
+                'pelvisModel':bs.getModel(t.pelvisModel),
+                'upperArmModel':bs.getModel(t.upperArmModel),
+                'foreArmModel':bs.getModel(t.foreArmModel),
+                'handModel':bs.getModel(t.handModel),
+                'upperLegModel':bs.getModel(t.upperLegModel),
+                'lowerLegModel':bs.getModel(t.lowerLegModel),
+                'toesModel':bs.getModel(t.toesModel)
+            }
+        else:
+            m = self.spazMedia[character]
+        return m
+
+class Spaz(bs.Actor):
+    """
+    category: Game Flow Classes
+    
+    Base class for various Spazzes.
+    A Spaz is the standard little humanoid character in the game.
+    It can be controlled by a player or by AI, and can have
+    various different appearances.  The name 'Spaz' is not to be
+    confused with the 'Spaz' character in the game, which is just
+    one of the skins available for instances of this class.
+
+    Attributes:
+
+       node
+          The 'spaz' bs.Node.
+    """
+    pointsMult = 1
+    curseTime = 5000
+    defaultBombCount = 1
+    defaultBombType = 'normal'
+    defaultBoxingGloves = False
+    defaultShields = False
+
+    def __init__(self, color=(1, 1, 1), highlight=(0.5, 0.5, 0.5),
+                 character="Spaz", sourcePlayer=None, startInvincible=True,
+                 canAcceptPowerups=True, powerupsExpire=False, demoMode=False):
+        """
+        Create a new spaz with the requested color, character, etc.
+        """
+        
+        bs.Actor.__init__(self)
+        activity = self.getActivity()
+        
+        factory = self.getFactory()
+
+        # we need to behave slightly different in the tutorial
+        self._demoMode = demoMode
+        
+        self.playBigDeathSound = False
+
+        # translate None into empty player-ref
+        if sourcePlayer is None: sourcePlayer = bs.Player(None)
+
+        # scales how much impacts affect us (most damage calcs)
+        self._impactScale = 1.0
+        
+        self.sourcePlayer = sourcePlayer
+        self._dead = False
+        if self._demoMode: # preserve old behavior
+            self._punchPowerScale = gBasePunchPowerScale
+        else:
+            self._punchPowerScale = factory.punchPowerScale
+        self.fly = bs.getSharedObject('globals').happyThoughtsMode
+        self._hockey = activity._map.isHockey
+        self._punchedNodes = set()
+        self._cursed = False
+        self._connectedToPlayer = None
+
+        materials = [factory.spazMaterial,
+                     bs.getSharedObject('objectMaterial'),
+                     bs.getSharedObject('playerMaterial')]
+        
+        rollerMaterials = [factory.rollerMaterial,
+                           bs.getSharedObject('playerMaterial')]
+        
+        extrasMaterials = []
+        
+        if canAcceptPowerups:
+            pam = bs.Powerup.getFactory().powerupAcceptMaterial
+            materials.append(pam)
+            rollerMaterials.append(pam)
+            extrasMaterials.append(pam)
+
+        media = factory._getMedia(character)
+        self.node = bs.newNode(
+            type="spaz",
+            delegate=self,
+            attrs={'color':color,
+                   'behaviorVersion':0 if demoMode else 1,
+                   'demoMode':True if demoMode else False,
+                   'highlight':highlight,
+                   'jumpSounds':media['jumpSounds'],
+                   'attackSounds':media['attackSounds'],
+                   'impactSounds':media['impactSounds'],
+                   'deathSounds':media['deathSounds'],
+                   'pickupSounds':media['pickupSounds'],
+                   'fallSounds':media['fallSounds'],
+                   'colorTexture':media['colorTexture'],
+                   'colorMaskTexture':media['colorMaskTexture'],
+                   'headModel':media['headModel'],
+                   'torsoModel':media['torsoModel'],
+                   'pelvisModel':media['pelvisModel'],
+                   'upperArmModel':media['upperArmModel'],
+                   'foreArmModel':media['foreArmModel'],
+                   'handModel':media['handModel'],
+                   'upperLegModel':media['upperLegModel'],
+                   'lowerLegModel':media['lowerLegModel'],
+                   'toesModel':media['toesModel'],
+                   'style':factory._getStyle(character),
+                   'fly':self.fly,
+                   'hockey':self._hockey,
+                   'materials':materials,
+                   'rollerMaterials':rollerMaterials,
+                   'extrasMaterials':extrasMaterials,
+                   'punchMaterials':(factory.punchMaterial,
+                                     bs.getSharedObject('attackMaterial')),
+                   'pickupMaterials':(factory.pickupMaterial,
+                                      bs.getSharedObject('pickupMaterial')),
+                   'invincible':startInvincible,
+                   'sourcePlayer':sourcePlayer})
+        self.shield = None
+
+        if startInvincible:
+            def _safeSetAttr(node, attr, val):
+                if node.exists(): setattr(node, attr, val)
+            bs.gameTimer(1000, bs.Call(_safeSetAttr, self.node,
+                                       'invincible', False))
+        self.hitPoints = 1000
+        self.hitPointsMax = 1000
+        self.bombCount = self.defaultBombCount
+        self._maxBombCount = self.defaultBombCount
+        self.bombTypeDefault = self.defaultBombType
+        self.bombType = self.bombTypeDefault
+        self.landMineCount = 0
+        self.blastRadius = 2.0
+        self.powerupsExpire = powerupsExpire
+        if self._demoMode: # preserve old behavior
+            self._punchCooldown = gBasePunchCooldown
+        else:
+            self._punchCooldown = factory.punchCooldown
+        self._jumpCooldown = 250
+        self._pickupCooldown = 0
+        self._bombCooldown = 0
+        self._hasBoxingGloves = False
+        if self.defaultBoxingGloves: self.equipBoxingGloves()
+        self.lastPunchTime = -9999
+        self.lastJumpTime = -9999
+        self.lastPickupTime = -9999
+        self.lastRunTime = -9999
+        self._lastRunValue = 0
+        self.lastBombTime = -9999
+        self._turboFilterTimes = {}
+        self._turboFilterTimeBucket = 0
+        self._turboFilterCounts = {}
+        self.frozen = False
+        self.shattered = False
+        self._lastHitTime = None
+        self._numTimesHit = 0
+        self._bombHeld = False
+        if self.defaultShields: self.equipShields()
+        self._droppedBombCallbacks = []
+
+        # deprecated stuff.. need to make these into lists
+        self.punchCallback = None
+        self.pickUpPowerupCallback = None
+
+    def onFinalize(self):
+        bs.Actor.onFinalize(self)
+
+        # release callbacks/refs so we don't wind up with dependency loops..
+        self._droppedBombCallbacks = []
+        self.punchCallback = None
+        self.pickUpPowerupCallback = None
+        
+    def addDroppedBombCallback(self, call):
+        """
+        Add a call to be run whenever this Spaz drops a bomb.
+        The spaz and the newly-dropped bomb are passed as arguments.
+        """
+        self._droppedBombCallbacks.append(call)
+                            
+    def isAlive(self):
+        """
+        Method override; returns whether ol' spaz is still kickin'.
+        """
+        return not self._dead
+
+    @classmethod
+    def getFactory(cls):
+        """
+        Returns the shared bs.SpazFactory object, creating it if necessary.
+        """
+        activity = bs.getActivity()
+        if activity is None: raise Exception("no current activity")
+        try: return activity._sharedSpazFactory
+        except Exception:
+            f = activity._sharedSpazFactory = SpazFactory()
+            return f
+
+    def exists(self):
+        return self.node.exists()
+
+    def _hideScoreText(self):
+        if self._scoreText.exists():
+            bs.animate(self._scoreText, 'scale',
+                       {0:self._scoreText.scale, 200:0})
+
+    def _turboFilterAddPress(self, source):
+        """
+        Can pass all button presses through here; if we see an obscene number
+        of them in a short time let's shame/pushish this guy for using turbo
+        """
+        t = bs.getNetTime()
+        tBucket = int(t/1000)
+        if tBucket == self._turboFilterTimeBucket:
+            # add only once per timestep (filter out buttons triggering
+            # multiple actions)
+            if t != self._turboFilterTimes.get(source, 0):
+                self._turboFilterCounts[source] = \
+                    self._turboFilterCounts.get(source, 0) + 1
+                self._turboFilterTimes[source] = t
+                # (uncomment to debug; prints what this count is at)
+                # bs.screenMessage( str(source) + " "
+                #                   + str(self._turboFilterCounts[source]))
+                if self._turboFilterCounts[source] == 15:
+                    
+                    # knock 'em out.  That'll learn 'em.
+                    self.node.handleMessage("knockout", 500.0)
+
+                    # also issue periodic notices about who is turbo-ing
+                    realTime = bs.getRealTime()
+                    global gLastTurboSpamWarningTime
+                    if realTime > gLastTurboSpamWarningTime + 30000:
+                        gLastTurboSpamWarningTime = realTime
+                        bs.screenMessage(
+                            bs.Lstr(translate=('statements',
+                                               ('Warning to ${NAME}:  '
+                                                'turbo / button-spamming knocks'
+                                                ' you out.')),
+                                    subs=[('${NAME}', self.node.name)]),
+                            color=(1, 0.5, 0))
+                        bs.playSound(bs.getSound('error'))
+        else:
+            self._turboFilterTimes = {}
+            self._turboFilterTimeBucket = tBucket
+            self._turboFilterCounts = {source:1}
+        
+    def setScoreText(self, t, color=(1, 1, 0.4), flash=False):
+        """
+        Utility func to show a message momentarily over our spaz that follows
+        him around; Handy for score updates and things.
+        """
+        colorFin = bs.getSafeColor(color)[:3]
+        if not self.node.exists(): return
+        try: exists = self._scoreText.exists()
+        except Exception: exists = False
+        if not exists:
+            startScale = 0.0
+            m = bs.newNode('math', owner=self.node, attrs={ 'input1':(0, 1.4, 0),
+                                                            'operation':'add' })
+            self.node.connectAttr('torsoPosition', m, 'input2')
+            self._scoreText = bs.newNode('text',
+                                          owner=self.node,
+                                          attrs={'text':t,
+                                                 'inWorld':True,
+                                                 'shadow':1.0,
+                                                 'flatness':1.0,
+                                                 'color':colorFin,
+                                                 'scale':0.02,
+                                                 'hAlign':'center'})
+            m.connectAttr('output', self._scoreText, 'position')
+        else:
+            self._scoreText.color = colorFin
+            startScale = self._scoreText.scale
+            self._scoreText.text = t
+        if flash:
+            combine = bs.newNode("combine", owner=self._scoreText,
+                                 attrs={'size':3})
+            sc = 1.8
+            offs = 0.5
+            t = 300
+            for i in range(3):
+                c1 = offs+sc*colorFin[i]
+                c2 = colorFin[i]
+                bs.animate(combine, 'input'+str(i), {0.5*t:c2,
+                                                   0.75*t:c1,
+                                                   1.0*t:c2})
+            combine.connectAttr('output', self._scoreText, 'color')
+            
+        bs.animate(self._scoreText, 'scale', {0:startScale, 200:0.02})
+        self._scoreTextHideTimer = bs.Timer(1000,
+                                            bs.WeakCall(self._hideScoreText))
+        
+    def onJumpPress(self):
+        """
+        Called to 'press jump' on this spaz;
+        used by player or AI connections.
+        """
+        if not self.node.exists(): return
+        t = bs.getGameTime()
+        if t - self.lastJumpTime >= self._jumpCooldown:
+            self.node.jumpPressed = True
+            self.lastJumpTime = t
+        self._turboFilterAddPress('jump')
+
+    def onJumpRelease(self):
+        """
+        Called to 'release jump' on this spaz;
+        used by player or AI connections.
+        """
+        if not self.node.exists(): return
+        self.node.jumpPressed = False
+
+    def onPickUpPress(self):
+        """
+        Called to 'press pick-up' on this spaz;
+        used by player or AI connections.
+        """
+        if not self.node.exists(): return
+        t = bs.getGameTime()
+        if t - self.lastPickupTime >= self._pickupCooldown:
+            self.node.pickUpPressed = True
+            self.lastPickupTime = t
+        self._turboFilterAddPress('pickup')
+
+    def onPickUpRelease(self):
+        """
+        Called to 'release pick-up' on this spaz;
+        used by player or AI connections.
+        """
+        if not self.node.exists(): return
+        self.node.pickUpPressed = False
+
+    def _onHoldPositionPress(self):
+        """
+        Called to 'press hold-position' on this spaz;
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+        self.node.holdPositionPressed = True
+        self._turboFilterAddPress('holdposition')
+
+    def _onHoldPositionRelease(self):
+        """
+        Called to 'release hold-position' on this spaz;
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+        self.node.holdPositionPressed = False
+
+    def onPunchPress(self):
+        """
+        Called to 'press punch' on this spaz;
+        used for player or AI connections.
+        """
+        if (not self.node.exists()
+            or self.frozen
+            or self.node.knockout > 0.0): return
+        t = bs.getGameTime()
+        if t - self.lastPunchTime >= self._punchCooldown:
+            if self.punchCallback is not None:
+                self.punchCallback(self)
+            self._punchedNodes = set() # reset this..
+            self.lastPunchTime = t
+            self.node.punchPressed = True
+            if not self.node.holdNode.exists():
+                bs.gameTimer(100, bs.WeakCall(self._safePlaySound,
+                                              self.getFactory().swishSound,
+                                              0.8))
+        self._turboFilterAddPress('punch')
+
+    def _safePlaySound(self, sound, volume):
+        """
+        Plays a sound at our position if we exist.
+        """
+        if self.node.exists():
+            bs.playSound(sound, volume, self.node.position)
+        
+    def onPunchRelease(self):
+        """
+        Called to 'release punch' on this spaz;
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+        self.node.punchPressed = False
+
+    def onBombPress(self):
+        """
+        Called to 'press bomb' on this spaz;
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+        
+        if self._dead or self.frozen: return
+        if self.node.knockout > 0.0: return
+        t = bs.getGameTime()
+        if t - self.lastBombTime >= self._bombCooldown:
+            self.lastBombTime = t
+            self.node.bombPressed = True
+            if not self.node.holdNode.exists(): self.dropBomb()
+        self._turboFilterAddPress('bomb')
+
+    def onBombRelease(self):
+        """
+        Called to 'release bomb' on this spaz; 
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+        self.node.bombPressed = False
+
+    def onRun(self, value):
+        """
+        Called to 'press run' on this spaz;
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+
+        t = bs.getGameTime()
+        self.lastRunTime = t
+        self.node.run = value
+
+        # filtering these events would be tough since its an analog
+        # value, but lets still pass full 0-to-1 presses along to
+        # the turbo filter to punish players if it looks like they're turbo-ing
+        if self._lastRunValue < 0.01 and value > 0.99:
+            self._turboFilterAddPress('run')
+            
+        self._lastRunValue = value
+            
+
+    def onFlyPress(self):
+        """
+        Called to 'press fly' on this spaz;
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+        # not adding a cooldown time here for now; slightly worried
+        # input events get clustered up during net-games and we'd wind up
+        # killing a lot and making it hard to fly.. should look into this.
+        self.node.flyPressed = True
+        self._turboFilterAddPress('fly')
+
+    def onFlyRelease(self):
+        """
+        Called to 'release fly' on this spaz;
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+        self.node.flyPressed = False
+
+    def onMove(self, x, y):
+        """
+        Called to set the joystick amount for this spaz;
+        used for player or AI connections.
+        """
+        if not self.node.exists(): return
+        self.node.handleMessage("move", x, y)
+        
+    def onMoveUpDown(self, value):
+        """
+        Called to set the up/down joystick amount on this spaz;
+        used for player or AI connections.
+        value will be between -32768 to 32767
+        WARNING: deprecated; use onMove instead.
+        """
+        if not self.node.exists(): return
+        self.node.moveUpDown = value
+
+    def onMoveLeftRight(self, value):
+        """
+        Called to set the left/right joystick amount on this spaz;
+        used for player or AI connections.
+        value will be between -32768 to 32767
+        WARNING: deprecated; use onMove instead.
+        """
+        if not self.node.exists(): return
+        self.node.moveLeftRight = value
+
+    def onPunched(self, damage):
+        """
+        Called when this spaz gets punched.
+        """
+        pass
+
+    def getDeathPoints(self, how):
+        'Get the points awarded for killing this spaz'
+        numHits = float(max(1, self._numTimesHit))
+        # base points is simply 10 for 1-hit-kills and 5 otherwise
+        importance = 2 if numHits < 2 else 1
+        return ((10 if numHits < 2 else 5) * self.pointsMult, importance)
+
+    def curse(self):
+        """
+        Give this poor spaz a curse;
+        he will explode in 5 seconds.
+        """
+        if not self._cursed:
+            factory = self.getFactory()
+            self._cursed = True
+            # add the curse material..
+            for attr in ['materials', 'rollerMaterials']:
+                materials = getattr(self.node, attr)
+                if not factory.curseMaterial in materials:
+                    setattr(self.node, attr,
+                            materials + (factory.curseMaterial,))
+
+            # -1 specifies no time limit
+            if self.curseTime == -1:
+                self.node.curseDeathTime = -1
+            else:
+                self.node.curseDeathTime = bs.getGameTime()+5000
+                bs.gameTimer(5000, bs.WeakCall(self.curseExplode))
+
+    def equipBoxingGloves(self):
+        """
+        Give this spaz some boxing gloves.
+        """
+        self.node.boxingGloves = 1
+        if self._demoMode: # preserve old behavior
+            self._punchPowerScale = 1.7
+            self._punchCooldown = 300
+        else:
+            factory = self.getFactory()
+            self._punchPowerScale = factory.punchPowerScaleGloves
+            self._punchCooldown = factory.punchCooldownGloves
+
+    def equipShields(self, decay=False):
+        """
+        Give this spaz a nice energy shield.
+        """
+
+        if not self.node.exists():
+            bs.printError('Can\'t equip shields; no node.')
+            return
+        
+        factory = self.getFactory()
+        if self.shield is None: 
+            self.shield = bs.newNode('shield', owner=self.node,
+                                     attrs={'color':(0.3, 0.2, 2.0),
+                                            'radius':1.3})
+            self.node.connectAttr('positionCenter', self.shield, 'position')
+        self.shieldHitPoints = self.shieldHitPointsMax = 650
+        self.shieldDecayRate = factory.shieldDecayRate if decay else 0
+        self.shield.hurt = 0
+        bs.playSound(factory.shieldUpSound, 1.0, position=self.node.position)
+
+        if self.shieldDecayRate > 0:
+            self.shieldDecayTimer = bs.Timer(500, bs.WeakCall(self.shieldDecay),
+                                             repeat=True)
+            self.shield.alwaysShowHealthBar = True # so user can see the decay
+
+    def shieldDecay(self):
+        'Called repeatedly to decay shield HP over time.'
+        if self.shield is not None and self.shield.exists():
+            self.shieldHitPoints = \
+                max(0, self.shieldHitPoints - self.shieldDecayRate)
+            self.shield.hurt = \
+                1.0 - float(self.shieldHitPoints)/self.shieldHitPointsMax
+            if self.shieldHitPoints <= 0:
+                self.shield.delete()
+                self.shield = None
+                self.shieldDecayTimer = None
+                bs.playSound(self.getFactory().shieldDownSound,
+                             1.0, position=self.node.position)
+        else:
+            self.shieldDecayTimer = None
+        
+    def handleMessage(self, msg):
+        self._handleMessageSanityCheck()
+
+        if isinstance(msg, bs.PickedUpMessage):
+            self.node.handleMessage("hurtSound")
+            self.node.handleMessage("pickedUp")
+            # this counts as a hit
+            self._numTimesHit += 1
+
+        elif isinstance(msg, bs.ShouldShatterMessage):
+            # eww; seems we have to do this in a timer or it wont work right
+            # (since we're getting called from within update() perhaps?..)
+            bs.gameTimer(1, bs.WeakCall(self.shatter))
+
+        elif isinstance(msg, bs.ImpactDamageMessage):
+            # eww; seems we have to do this in a timer or it wont work right
+            # (since we're getting called from within update() perhaps?..)
+            bs.gameTimer(1, bs.WeakCall(self._hitSelf, msg.intensity))
+
+        elif isinstance(msg, bs.PowerupMessage):
+            if self._dead: return True
+            if self.pickUpPowerupCallback is not None:
+                self.pickUpPowerupCallback(self)
+
+            if (msg.powerupType == 'tripleBombs'):
+                tex = bs.Powerup.getFactory().texBomb
+                self._flashBillboard(tex)
+                self.setBombCount(3)
+                if self.powerupsExpire:
+                    self.node.miniBillboard1Texture = tex
+                    t = bs.getGameTime()
+                    self.node.miniBillboard1StartTime = t
+                    self.node.miniBillboard1EndTime = t+gPowerupWearOffTime
+                    self._multiBombWearOffFlashTimer = \
+                        bs.Timer(gPowerupWearOffTime-2000,
+                                 bs.WeakCall(self._multiBombWearOffFlash))
+                    self._multiBombWearOffTimer = \
+                        bs.Timer(gPowerupWearOffTime,
+                                 bs.WeakCall(self._multiBombWearOff))
+            elif msg.powerupType == 'landMines':
+                self.setLandMineCount(min(self.landMineCount+3, 3))
+            elif msg.powerupType == 'impactBombs':
+                self.bombType = 'impact'
+                tex = self._getBombTypeTex()
+                self._flashBillboard(tex)
+                if self.powerupsExpire:
+                    self.node.miniBillboard2Texture = tex
+                    t = bs.getGameTime()
+                    self.node.miniBillboard2StartTime = t
+                    self.node.miniBillboard2EndTime = t+gPowerupWearOffTime
+                    self._bombWearOffFlashTimer = \
+                        bs.Timer( gPowerupWearOffTime-2000,
+                                  bs.WeakCall(self._bombWearOffFlash))
+                    self._bombWearOffTimer = \
+                        bs.Timer(gPowerupWearOffTime,
+                                 bs.WeakCall(self._bombWearOff))
+            elif msg.powerupType == 'stickyBombs':
+                self.bombType = 'sticky'
+                tex = self._getBombTypeTex()
+                self._flashBillboard(tex)
+                if self.powerupsExpire:
+                    self.node.miniBillboard2Texture = tex
+                    t = bs.getGameTime()
+                    self.node.miniBillboard2StartTime = t
+                    self.node.miniBillboard2EndTime = t+gPowerupWearOffTime
+                    self._bombWearOffFlashTimer = \
+                        bs.Timer(gPowerupWearOffTime-2000,
+                                 bs.WeakCall(self._bombWearOffFlash))
+                    self._bombWearOffTimer = \
+                        bs.Timer(gPowerupWearOffTime,
+                                 bs.WeakCall(self._bombWearOff))
+            elif msg.powerupType == 'punch':
+                self._hasBoxingGloves = True
+                tex = bs.Powerup.getFactory().texPunch
+                self._flashBillboard(tex)
+                self.equipBoxingGloves()
+                if self.powerupsExpire:
+                    self.node.boxingGlovesFlashing = 0
+                    self.node.miniBillboard3Texture = tex
+                    t = bs.getGameTime()
+                    self.node.miniBillboard3StartTime = t
+                    self.node.miniBillboard3EndTime = t+gPowerupWearOffTime
+                    self._boxingGlovesWearOffFlashTimer = \
+                        bs.Timer(gPowerupWearOffTime-2000,
+                                 bs.WeakCall(self._glovesWearOffFlash))
+                    self._boxingGlovesWearOffTimer = \
+                        bs.Timer(gPowerupWearOffTime,
+                                 bs.WeakCall(self._glovesWearOff))
+            elif msg.powerupType == 'shield':
+                factory = self.getFactory()
+                # let's allow powerup-equipped shields to lose hp over time
+                self.equipShields(
+                    decay=True if factory.shieldDecayRate > 0 else False)
+            elif msg.powerupType == 'curse':
+                self.curse()
+            elif (msg.powerupType == 'iceBombs'):
+                self.bombType = 'ice'
+                tex = self._getBombTypeTex()
+                self._flashBillboard(tex)
+                if self.powerupsExpire:
+                    self.node.miniBillboard2Texture = tex
+                    t = bs.getGameTime()
+                    self.node.miniBillboard2StartTime = t
+                    self.node.miniBillboard2EndTime = t+gPowerupWearOffTime
+                    self._bombWearOffFlashTimer = \
+                        bs.Timer(gPowerupWearOffTime-2000,
+                                 bs.WeakCall(self._bombWearOffFlash))
+                    self._bombWearOffTimer = \
+                        bs.Timer(gPowerupWearOffTime,
+                                 bs.WeakCall(self._bombWearOff))
+            elif (msg.powerupType == 'health'):
+                if self._cursed:
+                    self._cursed = False
+                    # remove cursed material
+                    factory = self.getFactory()
+                    for attr in ['materials', 'rollerMaterials']:
+                        materials = getattr(self.node, attr)
+                        if factory.curseMaterial in materials:
+                            setattr(self.node, attr,
+                                    tuple(m for m in materials
+                                          if m != factory.curseMaterial))
+                    self.node.curseDeathTime = 0
+                self.hitPoints = self.hitPointsMax
+                self._flashBillboard(bs.Powerup.getFactory().texHealth)
+                self.node.hurt = 0
+                self._lastHitTime = None
+                self._numTimesHit = 0
+                
+            self.node.handleMessage("flash")
+            if msg.sourceNode.exists():
+                msg.sourceNode.handleMessage(bs.PowerupAcceptMessage())
+            return True
+
+        elif isinstance(msg, bs.FreezeMessage):
+            if not self.node.exists(): return
+            if self.node.invincible == True:
+                bs.playSound(self.getFactory().blockSound, 1.0,
+                             position=self.node.position)
+                return
+            if self.shield is not None: return
+            if not self.frozen:
+                self.frozen = True
+                self.node.frozen = 1
+                bs.gameTimer(5000, bs.WeakCall(self.handleMessage,
+                                               bs.ThawMessage()))
+                # instantly shatter if we're already dead
+                # (otherwise its hard to tell we're dead)
+                if self.hitPoints <= 0:
+                    self.shatter()
+
+        elif isinstance(msg, bs.ThawMessage):
+            if self.frozen and not self.shattered and self.node.exists():
+                self.frozen = False
+                self.node.frozen = 0
+                
+        elif isinstance(msg, bs.HitMessage):
+            if not self.node.exists(): return
+            if self.node.invincible == True:
+                bs.playSound(self.getFactory().blockSound,
+                             1.0, position=self.node.position)
+                return True
+
+            # if we were recently hit, don't count this as another
+            # (so punch flurries and bomb pileups essentially count as 1 hit)
+            gameTime = bs.getGameTime()
+            if self._lastHitTime is None or gameTime-self._lastHitTime > 1000:
+                self._numTimesHit += 1
+                self._lastHitTime = gameTime
+            
+            mag = msg.magnitude * self._impactScale
+            velocityMag = msg.velocityMagnitude * self._impactScale
+
+            damageScale = 0.22
+
+            # if they've got a shield, deliver it to that instead..
+            if self.shield is not None:
+
+                if msg.flatDamage: damage = msg.flatDamage * self._impactScale
+                else:
+                    # hit our spaz with an impulse but tell it to only return
+                    # theoretical damage; not apply the impulse..
+                    self.node.handleMessage(
+                        "impulse", msg.pos[0], msg.pos[1], msg.pos[2],
+                        msg.velocity[0], msg.velocity[1], msg.velocity[2],
+                        mag , velocityMag, msg.radius, 1,
+                        msg.forceDirection[0], msg.forceDirection[1],
+                        msg.forceDirection[2])
+                    damage = damageScale * self.node.damage
+
+                self.shieldHitPoints -= damage
+
+                self.shield.hurt = (1.0 - float(self.shieldHitPoints)
+                                    /self.shieldHitPointsMax)
+                # its a cleaner event if a hit just kills the shield
+                # without damaging the player..
+                # however, massive damage events should still be able to
+                # damage the player.. this hopefully gives us a happy medium.
+                maxSpillover = self.getFactory().maxShieldSpilloverDamage
+                if self.shieldHitPoints <= 0:
+                    # fixme - transition out perhaps?..
+                    self.shield.delete()
+                    self.shield = None
+                    bs.playSound(self.getFactory().shieldDownSound, 1.0,
+                                 position=self.node.position)
+                    # emit some cool lookin sparks when the shield dies
+                    t = self.node.position
+                    bs.emitBGDynamics(position=(t[0], t[1]+0.9, t[2]),
+                                      velocity=self.node.velocity,
+                                      count=random.randrange(20, 30), scale=1.0,
+                                      spread=0.6, chunkType='spark')
+
+                else:
+                    bs.playSound(self.getFactory().shieldHitSound, 0.5,
+                                 position=self.node.position)
+
+                # emit some cool lookin sparks on shield hit
+                bs.emitBGDynamics(position=msg.pos,
+                                  velocity=(msg.forceDirection[0]*1.0,
+                                            msg.forceDirection[1]*1.0,
+                                            msg.forceDirection[2]*1.0),
+                                  count=min(30, 5+int(damage*0.005)),
+                                  scale=0.5, spread=0.3, chunkType='spark')
+
+                # if they passed our spillover threshold,
+                # pass damage along to spaz
+                if self.shieldHitPoints <= -maxSpillover:
+                    leftoverDamage = -maxSpillover-self.shieldHitPoints
+                    shieldLeftoverRatio = leftoverDamage/damage
+
+                    # scale down the magnitudes applied to spaz accordingly..
+                    mag *= shieldLeftoverRatio
+                    velocityMag *= shieldLeftoverRatio
+                else:
+                    return True # good job shield!
+            else: shieldLeftoverRatio = 1.0
+
+            if msg.flatDamage:
+                damage = (msg.flatDamage * self._impactScale
+                          * shieldLeftoverRatio)
+            else:
+                # hit it with an impulse and get the resulting damage
+                self.node.handleMessage(
+                    "impulse", msg.pos[0], msg.pos[1], msg.pos[2],
+                    msg.velocity[0], msg.velocity[1], msg.velocity[2],
+                    mag, velocityMag, msg.radius, 0,
+                    msg.forceDirection[0], msg.forceDirection[1],
+                    msg.forceDirection[2])
+
+                damage = damageScale * self.node.damage
+            self.node.handleMessage("hurtSound")
+
+            # play punch impact sound based on damage if it was a punch
+            if msg.hitType == 'punch':
+
+                self.onPunched(damage)
+
+                # if damage was significant, lets show it
+                if damage > 350:
+                    bsUtils.showDamageCount('-' + str(int(damage/10)) + "%",
+                                            msg.pos, msg.forceDirection)
+                                               
+                # lets always add in a super-punch sound with boxing
+                # gloves just to differentiate them
+                if msg.hitSubType == 'superPunch':
+                    bs.playSound(self.getFactory().punchSoundStronger, 1.0,
+                                 position=self.node.position)
+                if damage > 500:
+                    sounds = self.getFactory().punchSoundsStrong
+                    sound = sounds[random.randrange(len(sounds))]
+                else: sound = self.getFactory().punchSound
+                bs.playSound(sound, 1.0, position=self.node.position)
+
+                # throw up some chunks
+                bs.emitBGDynamics(position=msg.pos,
+                                  velocity=(msg.forceDirection[0]*0.5,
+                                            msg.forceDirection[1]*0.5,
+                                            msg.forceDirection[2]*0.5),
+                                  count=min(10, 1+int(damage*0.0025)),
+                                  scale=0.3, spread=0.03);
+
+                bs.emitBGDynamics(position=msg.pos,
+                                  chunkType='sweat',
+                                  velocity=(msg.forceDirection[0]*1.3,
+                                            msg.forceDirection[1]*1.3+5.0,
+                                            msg.forceDirection[2]*1.3),
+                                  count=min(30, 1+int(damage*0.04)),
+                                  scale=0.9,
+                                  spread=0.28);
+                # momentary flash
+                hurtiness = damage*0.003
+                punchPos = (msg.pos[0]+msg.forceDirection[0]*0.02,
+                            msg.pos[1]+msg.forceDirection[1]*0.02,
+                            msg.pos[2]+msg.forceDirection[2]*0.02)
+                flashColor = (1.0, 0.8, 0.4)
+                light = bs.newNode("light",
+                                   attrs={'position':punchPos,
+                                          'radius':0.12+hurtiness*0.12,
+                                          'intensity':0.3*(1.0+1.0*hurtiness),
+                                          'heightAttenuated':False,
+                                          'color':flashColor})
+                bs.gameTimer(60, light.delete)
+
+
+                flash = bs.newNode("flash",
+                                   attrs={'position':punchPos,
+                                          'size':0.17+0.17*hurtiness,
+                                          'color':flashColor})
+                bs.gameTimer(60, flash.delete)
+
+            if msg.hitType == 'impact':
+                bs.emitBGDynamics(position=msg.pos,
+                                  velocity=(msg.forceDirection[0]*2.0,
+                                            msg.forceDirection[1]*2.0,
+                                            msg.forceDirection[2]*2.0),
+                                  count=min(10, 1+int(damage*0.01)),
+                                  scale=0.4, spread=0.1);
+            if self.hitPoints > 0:
+                # its kinda crappy to die from impacts, so lets reduce
+                # impact damage by a reasonable amount if it'll keep us alive
+                if msg.hitType == 'impact' and damage > self.hitPoints:
+                    # drop damage to whatever puts us at 10 hit points,
+                    # or 200 less than it used to be whichever is greater
+                    # (so it *can* still kill us if its high enough)
+                    newDamage = max(damage-200, self.hitPoints-10)
+                    damage = newDamage
+                self.node.handleMessage("flash")
+                # if we're holding something, drop it
+                if damage > 0.0 and self.node.holdNode.exists():
+                    self.node.holdNode = bs.Node(None)
+                self.hitPoints -= damage
+                self.node.hurt = 1.0 - float(self.hitPoints)/self.hitPointsMax
+                # if we're cursed, *any* damage blows us up
+                if self._cursed and damage > 0:
+                    bs.gameTimer(50, bs.WeakCall(self.curseExplode,
+                                                 msg.sourcePlayer))
+                # if we're frozen, shatter.. otherwise die if we hit zero
+                if self.frozen and (damage > 200 or self.hitPoints <= 0):
+                    self.shatter()
+                elif self.hitPoints <= 0:
+                    self.node.handleMessage(bs.DieMessage(how='impact'))
+
+            # if we're dead, take a look at the smoothed damage val
+            # (which gives us a smoothed average of recent damage) and shatter
+            # us if its grown high enough
+            if self.hitPoints <= 0:
+                damageAvg = self.node.damageSmoothed * damageScale
+                if damageAvg > 1000:
+                    self.shatter()
+
+        elif isinstance(msg, _BombDiedMessage):
+            self.bombCount += 1
+        
+        elif isinstance(msg, bs.DieMessage):
+            wasDead = self._dead
+            self._dead = True
+            self.hitPoints = 0
+            if msg.immediate:
+                self.node.delete()
+            elif self.node.exists():
+                self.node.hurt = 1.0
+                if self.playBigDeathSound and not wasDead:
+                    bs.playSound(self.getFactory().singlePlayerDeathSound)
+                self.node.dead = True
+                bs.gameTimer(2000, self.node.delete)
+
+        elif isinstance(msg, bs.OutOfBoundsMessage):
+            # by default we just die here
+            self.handleMessage(bs.DieMessage(how='fall'))
+        elif isinstance(msg, bs.StandMessage):
+            self._lastStandPos = (msg.position[0], msg.position[1],
+                                  msg.position[2])
+            self.node.handleMessage("stand", msg.position[0], msg.position[1],
+                                    msg.position[2], msg.angle)
+        elif isinstance(msg, _CurseExplodeMessage):
+            self.curseExplode()
+        elif isinstance(msg, _PunchHitMessage):
+            node = bs.getCollisionInfo("opposingNode")
+
+            # only allow one hit per node per punch
+            if (node is not None and node.exists()
+                and not node in self._punchedNodes):
+                
+                punchMomentumAngular = (self.node.punchMomentumAngular
+                                        * self._punchPowerScale)
+                punchPower = self.node.punchPower * self._punchPowerScale
+
+                # ok here's the deal:  we pass along our base velocity for use
+                # in the impulse damage calculations since that is a more
+                # predictable value than our fist velocity, which is rather
+                # erratic. ...however we want to actually apply force in the
+                # direction our fist is moving so it looks better.. so we still
+                # pass that along as a direction ..perhaps a time-averaged
+                # fist-velocity would work too?.. should try that.
+                
+                # if its something besides another spaz, just do a muffled punch
+                # sound
+                if node.getNodeType() != 'spaz':
+                    sounds = self.getFactory().impactSoundsMedium
+                    sound = sounds[random.randrange(len(sounds))]
+                    bs.playSound(sound, 1.0, position=self.node.position)
+
+                t = self.node.punchPosition
+                punchDir = self.node.punchVelocity
+                v = self.node.punchMomentumLinear
+
+                self._punchedNodes.add(node)
+                node.handleMessage(
+                    bs.HitMessage(
+                        pos=t,
+                        velocity=v,
+                        magnitude=punchPower*punchMomentumAngular*110.0,
+                        velocityMagnitude=punchPower*40,
+                        radius=0,
+                        srcNode=self.node,
+                        sourcePlayer=self.sourcePlayer,
+                        forceDirection = punchDir,
+                        hitType='punch',
+                        hitSubType=('superPunch' if self._hasBoxingGloves
+                                    else 'default')))
+
+                # also apply opposite to ourself for the first punch only
+                # ..this is given as a constant force so that it is more
+                # noticable for slower punches where it matters.. for fast
+                # awesome looking punches its ok if we punch 'through'
+                # the target
+                mag = -400.0
+                if self._hockey: mag *= 0.5
+                if len(self._punchedNodes) == 1:
+                    self.node.handleMessage("kickBack", t[0], t[1], t[2],
+                                            punchDir[0], punchDir[1],
+                                            punchDir[2], mag)
+
+        elif isinstance(msg, _PickupMessage):
+            opposingNode, opposingBody = bs.getCollisionInfo('opposingNode',
+                                                            'opposingBody')
+
+            if opposingNode is None or not opposingNode.exists(): return True
+
+            # dont allow picking up of invincible dudes
+            try:
+                if opposingNode.invincible == True: return True
+            except Exception: pass
+
+            # if we're grabbing the pelvis of a non-shattered spaz, we wanna
+            # grab the torso instead
+            if (opposingNode.getNodeType() == 'spaz'
+                and not opposingNode.shattered and opposingBody == 4):
+                opposingBody = 1
+
+            # special case - if we're holding a flag, dont replace it
+            # ( hmm - should make this customizable or more low level )
+            held = self.node.holdNode
+            if (held is not None and held.exists()
+                and held.getNodeType() == 'flag'): return True
+            self.node.holdBody = opposingBody # needs to be set before holdNode
+            self.node.holdNode = opposingNode
+        else:
+            bs.Actor.handleMessage(self, msg)
+
+    def dropBomb(self):
+        """
+        Tell the spaz to drop one of his bombs, and returns
+        the resulting bomb object.
+        If the spaz has no bombs or is otherwise unable to
+        drop a bomb, returns None.
+        """
+
+        if (self.landMineCount <= 0 and self.bombCount <= 0) or self.frozen:
+            return
+        p = self.node.positionForward
+        v = self.node.velocity
+
+        if self.landMineCount > 0:
+            droppingBomb = False
+            self.setLandMineCount(self.landMineCount-1)
+            bombType = 'landMine'
+        else:
+            droppingBomb = True
+            bombType = self.bombType
+
+        bomb = bs.Bomb(position=(p[0], p[1] - 0.0, p[2]),
+                       velocity=(v[0], v[1], v[2]),
+                       bombType=bombType,
+                       blastRadius=self.blastRadius,
+                       sourcePlayer=self.sourcePlayer,
+                       owner=self.node).autoRetain()
+
+        if droppingBomb:
+            self.bombCount -= 1
+            bomb.node.addDeathAction(bs.WeakCall(self.handleMessage,
+                                                 _BombDiedMessage()))
+        self._pickUp(bomb.node)
+
+        for c in self._droppedBombCallbacks: c(self, bomb)
+        
+        return bomb
+
+    def _pickUp(self, node):
+        if self.node.exists() and node.exists():
+            self.node.holdBody = 0 # needs to be set before holdNode
+            self.node.holdNode = node
+        
+    def setLandMineCount(self, count):
+        """
+        Set the number of land-mines this spaz is carrying.
+        """
+        self.landMineCount = count
+        if self.node.exists():
+            if self.landMineCount != 0:
+                self.node.counterText = 'x'+str(self.landMineCount)
+                self.node.counterTexture = bs.Powerup.getFactory().texLandMines
+            else:
+                self.node.counterText = ''
+
+    def curseExplode(self, sourcePlayer=None):
+        """
+        Explode the poor spaz as happens when
+        a curse timer runs out.
+        """
+
+        # convert None to an empty player-ref
+        if sourcePlayer is None: sourcePlayer = bs.Player(None)
+        
+        if self._cursed and self.node.exists():
+            self.shatter(extreme=True)
+            self.handleMessage(bs.DieMessage())
+            activity = self._activity()
+            if activity:
+                bs.Blast(position=self.node.position,
+                         velocity=self.node.velocity,
+                         blastRadius=3.0, blastType='normal',
+                         sourcePlayer=(sourcePlayer if sourcePlayer.exists()
+                                       else self.sourcePlayer)).autoRetain()
+            self._cursed = False
+
+    def shatter(self, extreme=False):
+        """
+        Break the poor spaz into little bits.
+        """
+        if self.shattered: return
+        self.shattered = True
+        if self.frozen:
+            # momentary flash of light
+            light = bs.newNode('light',
+                               attrs={'position':self.node.position,
+                                      'radius':0.5,
+                                      'heightAttenuated':False,
+                                      'color': (0.8, 0.8, 1.0)})
+            
+            bs.animate(light, 'intensity', {0:3.0, 40:0.5, 80:0.07, 300:0})
+            bs.gameTimer(300, light.delete)
+            # emit ice chunks..
+            bs.emitBGDynamics(position=self.node.position,
+                              velocity=self.node.velocity,
+                              count=int(random.random()*10.0+10.0),
+                              scale=0.6, spread=0.2, chunkType='ice');
+            bs.emitBGDynamics(position=self.node.position,
+                              velocity=self.node.velocity,
+                              count=int(random.random()*10.0+10.0),
+                              scale=0.3, spread=0.2, chunkType='ice');
+
+            bs.playSound(self.getFactory().shatterSound, 1.0,
+                         position=self.node.position)
+        else:
+            bs.playSound(self.getFactory().splatterSound, 1.0,
+                         position=self.node.position)
+        self.handleMessage(bs.DieMessage())
+        self.node.shattered = 2 if extreme else 1
+
+    def _hitSelf(self, intensity):
+
+        # clean exit if we're dead..
+        try: pos = self.node.position
+        except Exception: return
+
+        self.handleMessage(bs.HitMessage(flatDamage=50.0*intensity,
+                                         pos=pos,
+                                         forceDirection=self.node.velocity,
+                                         hitType='impact'))
+        self.node.handleMessage("knockout", max(0.0, 50.0*intensity))
+        if intensity > 5: sounds = self.getFactory().impactSoundsHarder
+        elif intensity > 3: sounds = self.getFactory().impactSoundsHard
+        else: sounds = self.getFactory().impactSoundsMedium
+        s = sounds[random.randrange(len(sounds))]
+        bs.playSound(s, position=pos, volume=5.0)
+        
+    def _getBombTypeTex(self):
+        bombFactory = bs.Powerup.getFactory()
+        if self.bombType == 'sticky': return bombFactory.texStickyBombs
+        elif self.bombType == 'ice': return bombFactory.texIceBombs
+        elif self.bombType == 'impact': return bombFactory.texImpactBombs
+        else: raise Exception()
+        
+    def _flashBillboard(self, tex):
+        self.node.billboardTexture = tex
+        self.node.billboardCrossOut = False
+        bs.animate(self.node, "billboardOpacity",
+                   {0:0.0, 100:1.0, 400:1.0, 500:0.0})
+
+    def setBombCount(self, count):
+        'Sets the number of bombs this Spaz has.'
+        # we cant just set bombCount cuz some bombs may be laid currently
+        # so we have to do a relative diff based on max
+        diff = count - self._maxBombCount
+        self._maxBombCount += diff
+        self.bombCount += diff
+
+    def _glovesWearOffFlash(self):
+        if self.node.exists():
+            self.node.boxingGlovesFlashing = 1
+            self.node.billboardTexture = bs.Powerup.getFactory().texPunch
+            self.node.billboardOpacity = 1.0
+            self.node.billboardCrossOut = True
+
+    def _glovesWearOff(self):
+        if self._demoMode: # preserve old behavior
+            self._punchPowerScale = gBasePunchPowerScale
+            self._punchCooldown = gBasePunchCooldown
+        else:
+            factory = self.getFactory()
+            self._punchPowerScale = factory.punchPowerScale
+            self._punchCooldown = factory.punchCooldown
+        self._hasBoxingGloves = False
+        if self.node.exists():
+            bs.playSound(bs.Powerup.getFactory().powerdownSound,
+                         position=self.node.position)
+            self.node.boxingGloves = 0
+            self.node.billboardOpacity = 0.0
+
+    def _multiBombWearOffFlash(self):
+        if self.node.exists():
+            self.node.billboardTexture = bs.Powerup.getFactory().texBomb
+            self.node.billboardOpacity = 1.0
+            self.node.billboardCrossOut = True
+
+    def _multiBombWearOff(self):
+        self.setBombCount(self.defaultBombCount)
+        if self.node.exists():
+            bs.playSound(bs.Powerup.getFactory().powerdownSound,
+                         position=self.node.position)
+            self.node.billboardOpacity = 0.0
+
+    def _bombWearOffFlash(self):
+        if self.node.exists():
+            self.node.billboardTexture = self._getBombTypeTex()
+            self.node.billboardOpacity = 1.0
+            self.node.billboardCrossOut = True
+
+    def _bombWearOff(self):
+        self.bombType = self.bombTypeDefault
+        if self.node.exists():
+            bs.playSound(bs.Powerup.getFactory().powerdownSound,
+                         position=self.node.position)
+            self.node.billboardOpacity = 0.0
+
+
+class PlayerSpazDeathMessage(object):
+    """
+    category: Message Classes
+
+    A bs.PlayerSpaz has died.
+
+    Attributes:
+
+       spaz
+          The bs.PlayerSpaz that died.
+
+       killed
+          If True, the spaz was killed;
+          If False, they left the game or the round ended.
+
+       killerPlayer
+          The bs.Player that did the killing, or None.
+
+       how
+          The particular type of death.
+    """
+    def __init__(self, spaz, wasKilled, killerPlayer, how):
+        """
+        Instantiate a message with the given values.
+        """
+        self.spaz = spaz
+        self.killed = wasKilled
+        self.killerPlayer = killerPlayer
+        self.how = how
+
+class PlayerSpazHurtMessage(object):
+    """
+    category: Message Classes
+
+    A bs.PlayerSpaz was hurt.
+
+    Attributes:
+
+       spaz
+          The bs.PlayerSpaz that was hurt
+    """
+    def __init__(self, spaz):
+        """
+        Instantiate with the given bs.Spaz value.
+        """
+        self.spaz = spaz
+
+
+class PlayerSpaz(Spaz):
+    """
+    category: Game Flow Classes
+    
+    A bs.Spaz subclass meant to be controlled by a bs.Player.
+
+    When a PlayerSpaz dies, it delivers a bs.PlayerSpazDeathMessage
+    to the current bs.Activity. (unless the death was the result of the
+    player leaving the game, in which case no message is sent)
+
+    When a PlayerSpaz is hurt, it delivers a bs.PlayerSpazHurtMessage
+    to the current bs.Activity.
+    """
+
+
+    def __init__(self, color=(1, 1, 1), highlight=(0.5, 0.5, 0.5),
+                 character="Spaz", player=None, powerupsExpire=True):
+        """
+        Create a spaz for the provided bs.Player.
+        Note: this does not wire up any controls;
+        you must call connectControlsToPlayer() to do so.
+        """
+        # convert None to an empty player-ref
+        if player is None: player = bs.Player(None)
+        
+        Spaz.__init__(self, color=color, highlight=highlight,
+                      character=character, sourcePlayer=player,
+                      startInvincible=True, powerupsExpire=powerupsExpire)
+        self.lastPlayerAttackedBy = None # FIXME - should use empty player ref
+        self.lastAttackedTime = 0
+        self.lastAttackedType = None
+        self.heldCount = 0
+        self.lastPlayerHeldBy = None # FIXME - should use empty player ref here
+        self._player = player
+
+        # grab the node for this player and wire it to follow our spaz
+        # (so players' controllers know where to draw their guides, etc)
+        if player.exists():
+            playerNode = bs.getActivity()._getPlayerNode(player)
+            self.node.connectAttr('torsoPosition', playerNode, 'position')
+
+    def __superHandleMessage(self, msg):
+        super(PlayerSpaz, self).handleMessage(msg)
+        
+    def getPlayer(self):
+        """
+        Return the bs.Player associated with this spaz.
+        Note that while a valid player object will always be
+        returned, there is no guarantee that the player is still
+        in the game.  Call bs.Player.exists() on the return value
+        before doing anything with it.
+        """
+        return self._player
+
+    def connectControlsToPlayer(self, enableJump=True, enablePunch=True,
+                                enablePickUp=True, enableBomb=True,
+                                enableRun=True, enableFly=True):
+        """
+        Wire this spaz up to the provided bs.Player.
+        Full control of the character is given by default
+        but can be selectively limited by passing False
+        to specific arguments.
+        """
+        player = self.getPlayer()
+        
+        # reset any currently connected player and/or the player we're wiring up
+        if self._connectedToPlayer is not None:
+            if player != self._connectedToPlayer: player.resetInput()
+            self.disconnectControlsFromPlayer()
+        else: player.resetInput()
+
+        player.assignInputCall('upDown', self.onMoveUpDown)
+        player.assignInputCall('leftRight', self.onMoveLeftRight)
+        player.assignInputCall('holdPositionPress', self._onHoldPositionPress)
+        player.assignInputCall('holdPositionRelease',
+                               self._onHoldPositionRelease)
+
+        if enableJump:
+            player.assignInputCall('jumpPress', self.onJumpPress)
+            player.assignInputCall('jumpRelease', self.onJumpRelease)
+        if enablePickUp:
+            player.assignInputCall('pickUpPress', self.onPickUpPress)
+            player.assignInputCall('pickUpRelease', self.onPickUpRelease)
+        if enablePunch:
+            player.assignInputCall('punchPress', self.onPunchPress)
+            player.assignInputCall('punchRelease', self.onPunchRelease)
+        if enableBomb:
+            player.assignInputCall('bombPress', self.onBombPress)
+            player.assignInputCall('bombRelease', self.onBombRelease)
+        if enableRun:
+            player.assignInputCall('run', self.onRun)
+        if enableFly:
+            player.assignInputCall('flyPress', self.onFlyPress)
+            player.assignInputCall('flyRelease', self.onFlyRelease)
+
+        self._connectedToPlayer = player
+
+        
+    def disconnectControlsFromPlayer(self):
+        """
+        Completely sever any previously connected
+        bs.Player from control of this spaz.
+        """
+        if self._connectedToPlayer is not None:
+            self._connectedToPlayer.resetInput()
+            self._connectedToPlayer = None
+            # send releases for anything in case its held..
+            self.onMoveUpDown(0)
+            self.onMoveLeftRight(0)
+            self._onHoldPositionRelease()
+            self.onJumpRelease()
+            self.onPickUpRelease()
+            self.onPunchRelease()
+            self.onBombRelease()
+            self.onRun(0.0)
+            self.onFlyRelease()
+        else:
+            print ('WARNING: disconnectControlsFromPlayer() called for'
+                   ' non-connected player')
+
+    def handleMessage(self, msg):
+        self._handleMessageSanityCheck()
+        # keep track of if we're being held and by who most recently
+        if isinstance(msg, bs.PickedUpMessage):
+            self.__superHandleMessage(msg) # augment standard behavior
+            self.heldCount += 1
+            pickedUpBy = msg.node.sourcePlayer
+            if pickedUpBy is not None and pickedUpBy.exists():
+                self.lastPlayerHeldBy = pickedUpBy
+        elif isinstance(msg, bs.DroppedMessage):
+            self.__superHandleMessage(msg) # augment standard behavior
+            self.heldCount -= 1
+            if self.heldCount < 0:
+                print "ERROR: spaz heldCount < 0"
+            # let's count someone dropping us as an attack..
+            try: pickedUpBy = msg.node.sourcePlayer
+            except Exception: pickedUpBy = None
+            if pickedUpBy is not None and pickedUpBy.exists():
+                self.lastPlayerAttackedBy = pickedUpBy
+                self.lastAttackedTime = bs.getGameTime()
+                self.lastAttackedType = ('pickedUp', 'default')
+        elif isinstance(msg, bs.DieMessage):
+
+            # report player deaths to the game
+            if not self._dead:
+
+                # immediate-mode or left-game deaths don't count as 'kills'
+                killed = (msg.immediate==False and msg.how!='leftGame')
+
+                activity = self._activity()
+
+                if not killed:
+                    killerPlayer = None
+                else:
+                    # if this player was being held at the time of death,
+                    # the holder is the killer
+                    if (self.heldCount > 0
+                            and self.lastPlayerHeldBy is not None
+                            and self.lastPlayerHeldBy.exists()):
+                        killerPlayer = self.lastPlayerHeldBy
+                    else:
+                        # otherwise, if they were attacked by someone in the
+                        # last few seconds, that person's the killer..
+                        # otherwise it was a suicide.
+                        # FIXME - currently disabling suicides in Co-Op since
+                        # all bot kills would register as suicides; need to
+                        # change this from lastPlayerAttackedBy to something
+                        # like lastActorAttackedBy to fix that.
+                        if (self.lastPlayerAttackedBy is not None
+                                and self.lastPlayerAttackedBy.exists()
+                                and bs.getGameTime() - self.lastAttackedTime \
+                                < 4000):
+                            killerPlayer = self.lastPlayerAttackedBy
+                        else:
+                            # ok, call it a suicide unless we're in co-op
+                            if (activity is not None
+                                    and not isinstance(activity.getSession(),
+                                                       bs.CoopSession)):
+                                killerPlayer = self.getPlayer()
+                            else:
+                                killerPlayer = None
+                            
+                if killerPlayer is not None and not killerPlayer.exists():
+                    killerPlayer = None
+
+                # only report if both the player and the activity still exist
+                if (killed and activity is not None
+                    and self.getPlayer().exists()):
+                    activity.handleMessage(
+                        PlayerSpazDeathMessage(self, killed,
+                                               killerPlayer, msg.how))
+                    
+            self.__superHandleMessage(msg) # augment standard behavior
+
+        # keep track of the player who last hit us for point rewarding
+        elif isinstance(msg, bs.HitMessage):
+            if msg.sourcePlayer is not None and msg.sourcePlayer.exists():
+                self.lastPlayerAttackedBy = msg.sourcePlayer
+                self.lastAttackedTime = bs.getGameTime()
+                self.lastAttackedType = (msg.hitType, msg.hitSubType)
+            self.__superHandleMessage(msg) # augment standard behavior
+            activity = self._activity()
+            if activity is not None:
+                activity.handleMessage(PlayerSpazHurtMessage(self))
+        else:
+            Spaz.handleMessage(self, msg)
+
+
+class RespawnIcon(object):
+    """
+    category: Game Flow Classes
+
+    An icon with a countdown that appears alongside the screen;
+    used to indicate that a bs.Player is waiting to respawn.
+    """
+    
+    def __init__(self, player, respawnTime):
+        """
+        Instantiate with a given bs.Player and respawnTime (in milliseconds)
+        """
+        activity = bs.getActivity()
+        onRight = False
+        self._visible = True
+        if isinstance(bs.getSession(), bs.TeamsSession):
+            onRight = player.getTeam().getID()%2==1
+            # store a list of icons in the team
+            try:
+                respawnIcons = (player.getTeam()
+                                .gameData['_spazRespawnIconsRight'])
+            except Exception:
+                respawnIcons = (player.getTeam()
+                                .gameData['_spazRespawnIconsRight']) = {}
+            offsExtra = -20
+        else:
+            onRight = False
+            # store a list of icons in the activity
+            try: respawnIcons = activity._spazRespawnIconsRight
+            except Exception:
+                respawnIcons = activity._spazRespawnIconsRight = {}
+            if isinstance(activity.getSession(), bs.FreeForAllSession):
+                offsExtra = -150
+            else: offsExtra = -20
+
+        try:
+            maskTex = player.getTeam().gameData['_spazRespawnIconsMaskTex']
+        except Exception:
+            maskTex = player.getTeam().gameData['_spazRespawnIconsMaskTex'] = \
+                bs.getTexture('characterIconMask')
+
+        # now find the first unused slot and use that
+        index = 0
+        while (index in respawnIcons and respawnIcons[index]() is not None
+               and respawnIcons[index]()._visible):
+            index += 1
+        respawnIcons[index] = weakref.ref(self)
+
+        offs = offsExtra + index*-53
+        icon = player.getIcon()
+        texture = icon['texture']
+        hOffs = -10
+        self._image = bs.NodeActor(
+            bs.newNode('image',
+                       attrs={'texture':texture,
+                              'tintTexture':icon['tintTexture'],
+                              'tintColor':icon['tintColor'],
+                              'tint2Color':icon['tint2Color'],
+                              'maskTexture':maskTex,
+                              'position':(-40-hOffs if onRight
+                                          else 40+hOffs, -180+offs),
+                              'scale':(32, 32),
+                              'opacity':1.0,
+                              'absoluteScale':True,
+                              'attach':'topRight' if onRight else 'topLeft'}))
+        
+        bs.animate(self._image.node, 'opacity', {0:0, 200:0.7})
+
+        self._name = bs.NodeActor(
+            bs.newNode('text',
+                       attrs={'vAttach':'top',
+                              'hAttach':'right' if onRight else 'left',
+                              'text':bs.Lstr(value=player.getName()),
+                              'maxWidth':100,
+                              'hAlign':'center',
+                              'vAlign':'center',
+                              'shadow':1.0,
+                              'flatness':1.0,
+                              'color':bs.getSafeColor(icon['tintColor']),
+                              'scale':0.5,
+                              'position':(-40-hOffs if onRight
+                                          else 40+hOffs, -205+49+offs)}))
+        
+        bs.animate(self._name.node, 'scale', {0:0, 100:0.5})
+
+        self._text = bs.NodeActor(
+            bs.newNode('text',
+                       attrs={'position':(-60-hOffs if onRight
+                                          else 60+hOffs, -192+offs),
+                              'hAttach':'right' if onRight else 'left',
+                              'hAlign':'right' if onRight else 'left',
+                              'scale':0.9,
+                              'shadow':0.5,
+                              'flatness':0.5,
+                              'vAttach':'top',
+                              'color':bs.getSafeColor(icon['tintColor']),
+                              'text':''}))
+        
+        bs.animate(self._text.node, 'scale', {0:0, 100:0.9})
+
+        self._respawnTime = bs.getGameTime()+respawnTime
+        self._update()
+        self._timer = bs.Timer(1000, bs.WeakCall(self._update), repeat=True)
+
+    def _update(self):
+        remaining = int(round(self._respawnTime-bs.getGameTime())/1000.0)
+        if remaining > 0:
+            if self._text.node.exists():
+                self._text.node.text = str(remaining)
+        else: self._clear()
+            
+    def _clear(self):
+        self._visible = False
+        self._image = self._text = self._timer = self._name = None
+        
+
+
+class SpazBotPunchedMessage(object):
+    """
+    category: Message Classes
+
+    A bs.SpazBot got punched.
+
+    Attributes:
+
+       badGuy
+          The bs.SpazBot that got punched.
+
+       damage
+          How much damage was done to the bs.SpazBot.
+    """
+    
+    def __init__(self, badGuy, damage):
+        """
+        Instantiate a message with the given values.
+        """
+        self.badGuy = badGuy
+        self.damage = damage
+
+class SpazBotDeathMessage(object):
+    """
+    category: Message Classes
+
+    A bs.SpazBot has died.
+
+    Attributes:
+
+       badGuy
+          The bs.SpazBot that was killed.
+
+       killerPlayer
+          The bs.Player that killed it (or None).
+
+       how
+          The particular type of death.
+    """
+    
+    def __init__(self, badGuy, killerPlayer, how):
+        """
+        Instantiate with given values.
+        """
+        self.badGuy = badGuy
+        self.killerPlayer = killerPlayer
+        self.how = how
+
+        
+class SpazBot(Spaz):
+    """
+    category: Bot Classes
+
+    A really dumb AI version of bs.Spaz.
+    Add these to a bs.BotSet to use them.
+
+    Note: currently the AI has no real ability to
+    navigate obstacles and so should only be used
+    on wide-open maps.
+
+    When a SpazBot is killed, it delivers a bs.SpazBotDeathMessage
+    to the current activity.
+
+    When a SpazBot is punched, it delivers a bs.SpazBotPunchedMessage
+    to the current activity.
+    """
+
+    character = 'Spaz'
+    punchiness = 0.5
+    throwiness = 0.7
+    static = False
+    bouncy = False
+    run = False
+    chargeDistMin = 0.0 # when we can start a new charge
+    chargeDistMax = 2.0 # when we can start a new charge
+    runDistMin = 0.0 # how close we can be to continue running
+    chargeSpeedMin = 0.4
+    chargeSpeedMax = 1.0
+    throwDistMin = 5.0
+    throwDistMax = 9.0
+    throwRate = 1.0
+    defaultBombType = 'normal'
+    defaultBombCount = 3
+    startCursed = False
+    color=gDefaultBotColor
+    highlight=gDefaultBotHighlight
+
+    def __init__(self):
+        """
+        Instantiate a spaz-bot.
+        """
+        Spaz.__init__(self, color=self.color, highlight=self.highlight,
+                      character=self.character, sourcePlayer=None,
+                      startInvincible=False, canAcceptPowerups=False)
+
+        # if you need to add custom behavior to a bot, set this to a callable
+        # which takes one arg (the bot) and returns False if the bot's normal
+        # update should be run and True if not
+        self.updateCallback = None
+        self._map = weakref.ref(bs.getActivity().getMap())
+
+        self.lastPlayerAttackedBy = None # FIXME - should use empty player-refs
+        self.lastAttackedTime = 0
+        self.lastAttackedType = None
+        self.targetPointDefault = None
+        self.heldCount = 0
+        self.lastPlayerHeldBy = None # FIXME - should use empty player-refs here
+        self.targetFlag = None
+        self._chargeSpeed = 0.5*(self.chargeSpeedMin+self.chargeSpeedMax)
+        self._leadAmount = 0.5
+        self._mode = 'wait'
+        self._chargeClosingIn = False
+        self._lastChargeDist = 0.0
+        self._running = False
+        self._lastJumpTime = 0
+
+        # these cooldowns didnt exist when these bots were calibrated,
+        # so take them out of the equation
+        self._jumpCooldown = 0
+        self._pickupCooldown = 0
+        self._flyCooldown = 0
+        self._bombCooldown = 0
+
+        if self.startCursed: self.curse()
+            
+    def _getTargetPlayerPt(self):
+        """ returns the default player pt we're targeting """
+        bp = bs.Vector(*self.node.position)
+        closestLen = None
+        closestVel = None
+        for pp, pv in self._playerPts:
+
+            l = (pp-bp).length()
+            # ignore player-points that are significantly below the bot
+            # (keeps bots from following players off cliffs)
+            if (closestLen is None or l < closestLen) and (pp[1] > bp[1] - 5.0):
+                closestLen = l
+                closestVel = pv
+                closest = pp
+        if closestLen is not None:
+            return (bs.Vector(closest[0], closest[1], closest[2]),
+                    bs.Vector(closestVel[0], closestVel[1], closestVel[2]))
+        else:
+            return None, None
+
+    def _setPlayerPts(self, pts):
+        """
+        Provide the spaz-bot with the locations of players.
+        """
+        self._playerPts = pts
+
+    def _updateAI(self):
+        """
+        Should be called periodically to update the spaz' AI
+        """
+        
+        if self.updateCallback is not None:
+            if self.updateCallback(self) == True:
+                return # true means bot has been handled
+
+        t = self.node.position
+        ourPos = bs.Vector(t[0], 0, t[2])
+        canAttack = True
+
+        # if we're a flag-bearer, we're pretty simple-minded - just walk
+        # towards the flag and try to pick it up
+        if self.targetFlag is not None:
+
+            if not self.targetFlag.node.exists():
+                # our flag musta died :-C
+                self.targetFlag = None
+                return
+            if self.node.holdNode.exists():
+                try: holdingFlag = (self.node.holdNode.getNodeType() == 'flag')
+                except Exception: holdingFlag = False
+            else: holdingFlag = False
+            # if we're holding the flag, just walk left
+            if holdingFlag:
+                # just walk left
+                self.node.moveLeftRight = -1.0
+                self.node.moveUpDown = 0.0
+            # otherwise try to go pick it up
+            else:
+                targetPtRaw = bs.Vector(*self.targetFlag.node.position)
+                targetVel = bs.Vector(0, 0, 0)
+                diff = (targetPtRaw-ourPos)
+                diff = bs.Vector(diff[0], 0, diff[2]) # dont care about y
+                dist = diff.length()
+                toTarget = diff.normal()
+
+                # if we're holding some non-flag item, drop it
+                if self.node.holdNode.exists():
+                    self.node.pickUpPressed = True
+                    self.node.pickUpPressed = False
+                    return
+
+                # if we're a runner, run only when not super-near the flag
+                if self.run and dist > 3.0:
+                    self._running = True
+                    self.node.run = 1.0
+                else:
+                    self._running = False
+                    self.node.run = 0.0
+
+                self.node.moveLeftRight = toTarget.x()
+                self.node.moveUpDown = -toTarget.z()
+                if dist < 1.25:
+                    self.node.pickUpPressed = True
+                    self.node.pickUpPressed = False
+            return
+        # not a flag-bearer.. if we're holding anything but a bomb, drop it
+        else:
+            if self.node.holdNode.exists():
+                try: holdingBomb = \
+                   (self.node.holdNode.getNodeType() in ['bomb', 'prop'])
+                except Exception: holdingBomb = False
+                if not holdingBomb:
+                    self.node.pickUpPressed = True
+                    self.node.pickUpPressed = False
+                    return
+            
+        targetPtRaw, targetVel = self._getTargetPlayerPt()
+
+        if targetPtRaw is None:
+            # use default target if we've got one
+            if self.targetPointDefault is not None:
+                targetPtRaw = self.targetPointDefault
+                targetVel = bs.Vector(0, 0, 0)
+                canAttack = False
+            # with no target, we stop moving and drop whatever we're holding
+            else:
+                self.node.moveLeftRight = 0
+                self.node.moveUpDown = 0
+                if self.node.holdNode.exists():
+                    self.node.pickUpPressed = True
+                    self.node.pickUpPressed = False
+                return
+
+        # we dont want height to come into play
+        targetPtRaw.data[1] = 0
+        targetVel.data[1] = 0
+
+        distRaw = (targetPtRaw-ourPos).length()
+        # use a point out in front of them as real target
+        # (more out in front the farther from us they are)
+        targetPt = targetPtRaw + targetVel*distRaw*0.3*self._leadAmount
+
+        diff = (targetPt-ourPos)
+        dist = diff.length()
+        toTarget = diff.normal()
+
+        if self._mode == 'throw':
+            # we can only throw if alive and well..
+            if not self._dead and not self.node.knockout:
+
+                timeTillThrow = self._throwReleaseTime-bs.getGameTime()
+
+                if not self.node.holdNode.exists():
+                    # if we havnt thrown yet, whip out the bomb
+                    if not self._haveDroppedThrowBomb:
+                        self.dropBomb()
+                        self._haveDroppedThrowBomb = True
+                    # otherwise our lack of held node means we successfully
+                    # released our bomb.. lets retreat now
+                    else:
+                        self._mode = 'flee'
+
+                # oh crap we're holding a bomb.. better throw it.
+                elif timeTillThrow <= 0:
+                    # jump and throw..
+                    def _safePickup(node):
+                        if node.exists():
+                            self.node.pickUpPressed = True
+                            self.node.pickUpPressed = False
+                    if dist > 5.0:
+                        self.node.jumpPressed = True
+                        self.node.jumpPressed = False
+                        # throws:
+                        bs.gameTimer(100, bs.Call(_safePickup, self.node))
+                    else:
+                        # throws:
+                        bs.gameTimer(1, bs.Call(_safePickup, self.node))
+
+                if self.static:
+                    if timeTillThrow < 300:
+                        speed = 1.0
+                    elif timeTillThrow < 700 and dist > 3.0:
+                        speed = -1.0 # whiplash for long throws
+                    else:
+                        speed = 0.02
+                else:
+                    if timeTillThrow < 700:
+                        # right before throw charge full speed towards target
+                        speed = 1.0
+                    else:
+                        # earlier we can hold or move backward for a whiplash
+                        speed = 0.0125
+                self.node.moveLeftRight = toTarget.x() * speed
+                self.node.moveUpDown = toTarget.z() * -1.0 * speed
+
+        elif self._mode == 'charge':
+            if random.random() < 0.3:
+                self._chargeSpeed = random.uniform(self.chargeSpeedMin,
+                                                   self.chargeSpeedMax)
+                # if we're a runner we run during charges *except when near
+                # an edge (otherwise we tend to fly off easily)
+                if self.run and distRaw > self.runDistMin:
+                    self._leadAmount = 0.3
+                    self._running = True
+                    self.node.run = 1.0
+                else:
+                    self._leadAmont = 0.01
+                    self._running = False
+                    self.node.run = 0.0
+
+            self.node.moveLeftRight = toTarget.x() * self._chargeSpeed
+            self.node.moveUpDown = toTarget.z() * -1.0*self._chargeSpeed
+
+        elif self._mode == 'wait':
+            # every now and then, aim towards our target..
+            # other than that, just stand there
+            if bs.getGameTime()%1234 < 100:
+                self.node.moveLeftRight = toTarget.x() * (400.0/33000)
+                self.node.moveUpDown = toTarget.z() * (-400.0/33000)
+            else:
+                self.node.moveLeftRight = 0
+                self.node.moveUpDown = 0
+
+        elif self._mode == 'flee':
+            # even if we're a runner, only run till we get away from our
+            # target (if we keep running we tend to run off edges)
+            if self.run and dist < 3.0:
+                self._running = True
+                self.node.run = 1.0
+            else:
+                self._running = False
+                self.node.run = 0.0
+            self.node.moveLeftRight = toTarget.x() * -1.0
+            self.node.moveUpDown = toTarget.z()
+
+        # we might wanna switch states unless we're doing a throw
+        # (in which case thats our sole concern)
+        if self._mode != 'throw':
+
+            # if we're currently charging, keep track of how far we are
+            # from our target.. when this value increases it means our charge
+            # is over (ran by them or something)
+            if self._mode == 'charge':
+                if (self._chargeClosingIn and dist < 3.0
+                        and dist > self._lastChargeDist):
+                    self._chargeClosingIn = False
+                self._lastChargeDist = dist
+
+            # if we have a clean shot, throw!
+            if (dist >= self.throwDistMin and dist < self.throwDistMax
+                and random.random() < self.throwiness and canAttack):
+                self._mode = 'throw'
+                self._leadAmount = ((0.4+random.random()*0.6) if distRaw > 4.0
+                                    else (0.1+random.random()*0.4))
+                self._haveDroppedThrowBomb = False
+                self._throwReleaseTime = (bs.getGameTime()
+                                          + (1.0/self.throwRate)
+                                          *(800 + int(1300*random.random())))
+
+            # if we're static, always charge (which for us means barely move)
+            elif self.static:
+                self._mode = 'wait'
+                
+            # if we're too close to charge (and arent in the middle of an
+            # existing charge) run away
+            elif dist < self.chargeDistMin and not self._chargeClosingIn:
+                # ..unless we're near an edge, in which case we got no choice
+                # but to charge..
+                if self._map()._isPointNearEdge(ourPos, self._running):
+                    if self._mode != 'charge':
+                        self._mode = 'charge'
+                        self._leadAmount = 0.2
+                        self._chargeClosingIn = True
+                        self._lastChargeDist = dist
+                else:
+                    self._mode = 'flee'
+
+            # we're within charging distance, backed against an edge, or farther
+            # than our max throw distance.. chaaarge!
+            elif (dist < self.chargeDistMax
+                  or dist > self.throwDistMax
+                  or self._map()._isPointNearEdge(ourPos, self._running)):
+                if self._mode != 'charge':
+                    self._mode = 'charge'
+                    self._leadAmount = 0.01
+                    self._chargeClosingIn = True
+                    self._lastChargeDist = dist
+
+            # we're too close to throw but too far to charge - either run
+            # away or just chill if we're near an edge
+            elif dist < self.throwDistMin:
+                # charge if either we're within charge range or
+                # cant retreat to throw
+                self._mode = 'flee'
+
+            # do some awesome jumps if we're running
+            if ((self._running
+                 and dist > 1.2 and dist < 2.2
+                 and bs.getGameTime()-self._lastJumpTime > 1000)
+                or (self.bouncy
+                    and bs.getGameTime()-self._lastJumpTime > 400
+                    and random.random() < 0.5)):
+                self._lastJumpTime = bs.getGameTime()
+                self.node.jumpPressed = True
+                self.node.jumpPressed = False
+                
+            # throw punches when real close
+            if dist < (1.6 if self._running else 1.2) and canAttack:
+                if random.random() < self.punchiness:
+                    self.onPunchPress()
+                    self.onPunchRelease()
+
+    def __superHandleMessage(self, msg):
+        super(SpazBot, self).handleMessage(msg)
+
+    def onPunched(self, damage):
+        """
+        Method override; sends bs.SpazBotPunchedMessage to the current activity.
+        """
+        bs.getActivity().handleMessage(SpazBotPunchedMessage(self, damage))
+
+    def onFinalize(self):
+        Spaz.onFinalize(self)
+        # we're being torn down; release
+        # our callback(s) so there's no chance of them
+        # keeping activities or other things alive..
+        self.updateCallback = None
+
+    def handleMessage(self, msg):
+        self._handleMessageSanityCheck()
+
+        # keep track of if we're being held and by who most recently
+        if isinstance(msg, bs.PickedUpMessage):
+            self.__superHandleMessage(msg) # augment standard behavior
+            self.heldCount += 1
+            pickedUpBy = msg.node.sourcePlayer
+            if pickedUpBy is not None and pickedUpBy.exists():
+                self.lastPlayerHeldBy = pickedUpBy
+
+        elif isinstance(msg, bs.DroppedMessage):
+            self.__superHandleMessage(msg) # augment standard behavior
+            self.heldCount -= 1
+            if self.heldCount < 0:
+                print "ERROR: spaz heldCount < 0"
+            # let's count someone dropping us as an attack..
+            try:
+                if msg.node.exists(): pickedUpBy = msg.node.sourcePlayer
+                else: pickedUpBy = bs.Player(None) # empty player ref
+            except Exception as e:
+                print 'EXC on SpazBot DroppedMessage:', e
+                pickedUpBy = bs.Player(None) # empty player ref
+
+            if pickedUpBy.exists():
+                self.lastPlayerAttackedBy = pickedUpBy
+                self.lastAttackedTime = bs.getGameTime()
+                self.lastAttackedType = ('pickedUp', 'default')
+            
+        elif isinstance(msg, bs.DieMessage):
+
+            # report normal deaths for scoring purposes
+            if not self._dead and not msg.immediate:
+
+                # if this guy was being held at the time of death, the
+                # holder is the killer
+                if (self.heldCount > 0 and self.lastPlayerHeldBy is not None
+                        and self.lastPlayerHeldBy.exists()):
+                    killerPlayer = self.lastPlayerHeldBy
+                else:
+                    # otherwise if they were attacked by someone in the
+                    # last few seconds that person's the killer..
+                    # otherwise it was a suicide
+                    if (self.lastPlayerAttackedBy is not None
+                           and self.lastPlayerAttackedBy.exists()
+                           and bs.getGameTime() - self.lastAttackedTime < 4000):
+                        killerPlayer = self.lastPlayerAttackedBy
+                    else:
+                        killerPlayer = None
+                activity = self._activity()
+
+                if killerPlayer is not None and not killerPlayer.exists():
+                    killerPlayer = None
+                if activity is not None:
+                    activity.handleMessage(
+                        SpazBotDeathMessage(self, killerPlayer, msg.how))
+            self.__superHandleMessage(msg) # augment standard behavior
+
+        # keep track of the player who last hit us for point rewarding
+        elif isinstance(msg, bs.HitMessage):
+            if msg.sourcePlayer is not None and msg.sourcePlayer.exists():
+                self.lastPlayerAttackedBy = msg.sourcePlayer
+                self.lastAttackedTime = bs.getGameTime()
+                self.lastAttackedType = (msg.hitType, msg.hitSubType)
+            self.__superHandleMessage(msg)
+        else:
+            Spaz.handleMessage(self, msg)
+
+            
+class BomberBot(SpazBot):
+    """
+    category: Bot Classes
+    
+    A bot that throws regular bombs
+    and occasionally punches.
+    """
+    character='Spaz'
+    punchiness=0.3
+
+    
+class BomberBotLame(BomberBot):
+    """
+    category: Bot Classes
+    
+    A less aggressive yellow version of bs.BomberBot.
+    """
+    color=gLameBotColor
+    highlight=gLameBotHighlight
+    punchiness = 0.2
+    throwRate = 0.7
+    throwiness = 0.1
+    chargeSpeedMin = 0.6
+    chargeSpeedMax = 0.6
+
+    
+class BomberBotStaticLame(BomberBotLame):
+    """
+    category: Bot Classes
+    
+    A less aggressive yellow version of bs.BomberBot
+    who generally stays in one place.
+    """
+    static = True
+    throwDistMin = 0.0
+
+    
+class BomberBotStatic(BomberBot):
+    """
+    category: Bot Classes
+    
+    A version of bs.BomberBot
+    who generally stays in one place.
+    """
+    static = True
+    throwDistMin = 0.0
+
+
+class BomberBotPro(BomberBot):
+    """
+    category: Bot Classes
+    
+    A more aggressive red version of bs.BomberBot.
+    """
+    pointsMult = 2
+    color=gProBotColor
+    highlight = gProBotHighlight
+    defaultBombCount = 3
+    defaultBoxingGloves = True
+    punchiness = 0.7
+    throwRate = 1.3
+    run = True
+    runDistMin = 6.0
+
+    
+class BomberBotProShielded(BomberBotPro):
+    """
+    category: Bot Classes
+    
+    A more aggressive red version of bs.BomberBot
+    who starts with shields.
+    """
+    pointsMult = 3
+    defaultShields = True
+
+    
+class BomberBotProStatic(BomberBotPro):
+    """
+    category: Bot Classes
+    
+    A more aggressive red version of bs.BomberBot
+    who generally stays in one place.
+    """
+    static = True
+    throwDistMin = 0.0
+
+class BomberBotProStaticShielded(BomberBotProShielded):
+    """
+    category: Bot Classes
+    
+    A more aggressive red version of bs.BomberBot
+    who starts with shields and
+    who generally stays in one place.
+    """
+    static = True
+    throwDistMin = 0.0
+
+    
+class ToughGuyBot(SpazBot):
+    """
+    category: Bot Classes
+    
+    A manly bot who walks and punches things.
+    """
+    character = 'Kronk'
+    punchiness = 0.9
+    chargeDistMax = 9999.0
+    chargeSpeedMin = 1.0
+    chargeSpeedMax = 1.0
+    throwDistMin = 9999
+    throwDistMax = 9999
+
+    
+class ToughGuyBotLame(ToughGuyBot):
+    """
+    category: Bot Classes
+    
+    A less aggressive yellow version of bs.ToughGuyBot.
+    """
+    color=gLameBotColor
+    highlight=gLameBotHighlight
+    punchiness = 0.3
+    chargeSpeedMin = 0.6
+    chargeSpeedMax = 0.6
+
+    
+class ToughGuyBotPro(ToughGuyBot):
+    """
+    category: Bot Classes
+    
+    A more aggressive red version of bs.ToughGuyBot.
+    """
+    color=gProBotColor
+    highlight=gProBotHighlight
+    run = True
+    runDistMin = 4.0
+    defaultBoxingGloves = True
+    punchiness = 0.95
+    pointsMult = 2
+
+    
+class ToughGuyBotProShielded(ToughGuyBotPro):
+    """
+    category: Bot Classes
+    
+    A more aggressive version of bs.ToughGuyBot
+    who starts with shields.
+    """
+    defaultShields = True
+    pointsMult = 3
+
+    
+class NinjaBot(SpazBot):
+    """
+    category: Bot Classes
+    
+    A speedy attacking melee bot.
+    """
+
+    character = 'Snake Shadow'
+    punchiness = 1.0
+    run = True
+    chargeDistMin = 10.0
+    chargeDistMax = 9999.0
+    chargeSpeedMin = 1.0
+    chargeSpeedMax = 1.0
+    throwDistMin = 9999
+    throwDistMax = 9999
+    pointsMult = 2
+
+    
+class BunnyBot(SpazBot):
+    """
+    category: Bot Classes
+    
+    A speedy attacking melee bot.
+    """
+
+    color=(1, 1, 1)
+    highlight=(1.0, 0.5, 0.5)
+    character = 'Easter Bunny'
+    punchiness = 1.0
+    run = True
+    bouncy = True
+    defaultBoxingGloves = True
+    chargeDistMin = 10.0
+    chargeDistMax = 9999.0
+    chargeSpeedMin = 1.0
+    chargeSpeedMax = 1.0
+    throwDistMin = 9999
+    throwDistMax = 9999
+    pointsMult = 2
+
+    
+class NinjaBotPro(NinjaBot):
+    """
+    category: Bot Classes
+    
+    A more aggressive red bs.NinjaBot.
+    """
+    color=gProBotColor
+    highlight=gProBotHighlight
+    defaultShields = True
+    defaultBoxingGloves = True
+    pointsMult = 3
+
+    
+class NinjaBotProShielded(NinjaBotPro):
+    """
+    category: Bot Classes
+    
+    A more aggressive red bs.NinjaBot
+    who starts with shields.
+    """
+    defaultShields = True
+    pointsMult = 4
+
+    
+class ChickBot(SpazBot):
+    """
+    category: Bot Classes
+    
+    A slow moving bot with impact bombs.
+    """
+    character = 'Zoe'
+    punchiness = 0.75
+    throwiness = 0.7
+    chargeDistMax = 1.0
+    chargeSpeedMin = 0.3
+    chargeSpeedMax = 0.5
+    throwDistMin = 3.5
+    throwDistMax = 5.5
+    defaultBombType = 'impact'
+    pointsMult = 2
+
+    
+class ChickBotStatic(ChickBot):
+    """
+    category: Bot Classes
+    
+    A bs.ChickBot who generally stays in one place.
+    """
+    static = True
+    throwDistMin = 0.0
+
+    
+class ChickBotPro(ChickBot):
+    """
+    category: Bot Classes
+    
+    A more aggressive red version of bs.ChickBot.
+    """
+    color=gProBotColor
+    highlight=gProBotHighlight
+    defaultBombCount = 3
+    defaultBoxingGloves = True
+    chargeSpeedMin = 1.0
+    chargeSpeedMax = 1.0
+    punchiness = 0.9
+    throwRate = 1.3
+    run = True
+    runDistMin = 6.0
+    pointsMult = 3
+
+    
+class ChickBotProShielded(ChickBotPro):
+    """
+    category: Bot Classes
+    
+    A more aggressive red version of bs.ChickBot
+    who starts with shields.
+    """
+    defaultShields = True
+    pointsMult = 4
+
+    
+class MelBot(SpazBot):
+    """
+    category: Bot Classes
+    
+    A crazy bot who runs and throws sticky bombs.
+    """
+    character = 'Mel'
+    punchiness = 0.9
+    throwiness = 1.0
+    run = True
+    chargeDistMin = 4.0
+    chargeDistMax = 10.0
+    chargeSpeedMin = 1.0
+    chargeSpeedMax = 1.0
+    throwDistMin = 0.0
+    throwDistMax = 4.0
+    throwRate = 2.0
+    defaultBombType = 'sticky'
+    defaultBombCount = 3
+    pointsMult = 3
+
+    
+class MelBotStatic(MelBot):
+    """
+    category: Bot Classes
+    
+    A crazy bot who throws sticky-bombs but generally stays in one place.
+    """
+    static = True
+
+    
+class PirateBot(SpazBot):
+    """
+    category: Bot Classes
+    
+    A bot who runs and explodes in 5 seconds.
+    """
+    character = 'Jack Morgan'
+    run = True
+    chargeDistMin = 0.0
+    chargeDistMax = 9999
+    chargeSpeedMin = 1.0
+    chargeSpeedMax = 1.0
+    throwDistMin = 9999
+    throwDistMax = 9999
+    startCursed = True
+    pointsMult = 4
+
+    
+class PirateBotNoTimeLimit(PirateBot):
+    """
+    category: Bot Classes
+    
+    A bot who runs but does not explode on his own.
+    """
+    curseTime = -1
+
+    
+class PirateBotShielded(PirateBot):
+    """
+    category: Bot Classes
+    
+    A bs.PirateBot who starts with shields.
+    """
+    defaultShields = True
+    pointsMult = 5
+
+    
+class BotSet(object):
+    """
+    category: Bot Classes
+    
+    A container/controller for one or more bs.SpazBots.
+    """
+    def __init__(self):
+        """
+        Create a bot-set.
+        """
+        # we spread our bots out over a few lists so we can update
+        # them in a staggered fashion
+        self._botListCount = 5
+        self._botAddList = 0
+        self._botUpdateList = 0
+        self._botLists = [[] for i in range(self._botListCount)]
+        self._spawnSound = bs.getSound('spawn')
+        self._spawningCount = 0
+        self.startMoving()
+
+    def __del__(self):
+        self.clear()
+
+    def spawnBot(self, botType, pos, spawnTime=3000, onSpawnCall=None):
+        """
+        Spawn a bot from this set.
+        """
+        bsUtils.Spawner(pt=pos, spawnTime=spawnTime,
+                        sendSpawnMessage=False,
+                        spawnCallback=bs.Call(self._spawnBot, botType,
+                                              pos, onSpawnCall))
+        self._spawningCount += 1
+
+    def _spawnBot(self, botType, pos, onSpawnCall):
+        spaz = botType()
+        bs.playSound(self._spawnSound, position=pos)
+        spaz.node.handleMessage("flash")
+        spaz.node.isAreaOfInterest = 0
+        spaz.handleMessage(bs.StandMessage(pos, random.uniform(0, 360)))
+        self.addBot(spaz)
+        self._spawningCount -= 1
+        if onSpawnCall is not None: onSpawnCall(spaz)
+        
+    def haveLivingBots(self):
+        """
+        Returns whether any bots in the set are alive or spawning.
+        """
+        haveLiving = any((any((not a._dead for a in l))
+                          for l in self._botLists))
+        haveSpawning = True if self._spawningCount > 0 else False
+        return (haveLiving or haveSpawning)
+
+
+    def getLivingBots(self):
+        """
+        Returns the living bots in the set.
+        """
+        bots = []
+        for l in self._botLists:
+            for b in l:
+                if not b._dead: bots.append(b)
+        return bots
+
+    def _update(self):
+
+        # update one of our bot lists each time through..
+        # first off, remove dead bots from the list
+        # (we check exists() here instead of dead.. we want to keep them
+        # around even if they're just a corpse)
+        try:
+            botList = self._botLists[self._botUpdateList] = \
+                [b for b in self._botLists[self._botUpdateList] if b.exists()]
+        except Exception:
+            bs.printException("error updating bot list: "
+                              +str(self._botLists[self._botUpdateList]))
+        self._botUpdateList = (self._botUpdateList+1)%self._botListCount
+
+        # update our list of player points for the bots to use
+        playerPts = []
+        for player in bs.getActivity().players:
+            try:
+                if player.isAlive():
+                    playerPts.append((bs.Vector(*player.actor.node.position),
+                                     bs.Vector(*player.actor.node.velocity)))
+            except Exception:
+                bs.printException('error on bot-set _update')
+
+        for b in botList:
+            b._setPlayerPts(playerPts)
+            b._updateAI()
+
+    def clear(self):
+        """
+        Immediately clear out any bots in the set.
+        """
+        # dont do this if the activity is shutting down or dead
+        activity = bs.getActivity(exceptionOnNone=False)
+        if activity is None or activity.isFinalized(): return
+        
+        for i in range(len(self._botLists)):
+            for b in self._botLists[i]:
+                b.handleMessage(bs.DieMessage(immediate=True))
+            self._botLists[i] = []
+        
+    def celebrate(self, duration):
+        """
+        Tell all living bots in the set to celebrate momentarily
+        while continuing onward with their evil bot activities.
+        """
+        for l in self._botLists:
+            for b in l:
+                if b.node.exists():
+                    b.node.handleMessage('celebrate', duration)
+
+    def startMoving(self):
+        """
+        Starts processing bot AI updates and let them start doing their thing.
+        """
+        self._botUpdateTimer = bs.Timer(50, bs.WeakCall(self._update),
+                                        repeat=True)
+                    
+    def stopMoving(self):
+        """
+        Tell all bots to stop moving and stops
+        updating their AI.
+        Useful when players have won and you want the
+        enemy bots to just stand and look bewildered.
+        """
+        self._botUpdateTimer = None
+        for l in self._botLists:
+            for b in l:
+                if b.node.exists():
+                    b.node.moveLeftRight = 0
+                    b.node.moveUpDown = 0
+        
+    def finalCelebrate(self):
+        """
+        Tell all bots in the set to stop what they were doing
+        and just jump around and celebrate.  Use this when
+        the bots have won a game.
+        """
+        self._botUpdateTimer = None
+        # at this point stop doing anything but jumping and celebrating
+        for l in self._botLists:
+            for b in l:
+                if b.node.exists():
+                    b.node.moveLeftRight = 0
+                    b.node.moveUpDown = 0
+                    bs.gameTimer(random.randrange(0, 500),
+                                 bs.Call(b.node.handleMessage,
+                                         'celebrate', 10000))
+                    jumpDuration = random.randrange(400, 500)
+                    j = random.randrange(0, 200)
+                    for i in range(10):
+                        b.node.jumpPressed = True
+                        b.node.jumpPressed = False
+                        j += jumpDuration
+                    bs.gameTimer(random.randrange(0, 1000),
+                                 bs.Call(b.node.handleMessage, 'attackSound'))
+                    bs.gameTimer(random.randrange(1000, 2000),
+                                 bs.Call(b.node.handleMessage, 'attackSound'))
+                    bs.gameTimer(random.randrange(2000, 3000),
+                                 bs.Call(b.node.handleMessage, 'attackSound'))
+
+    def addBot(self, bot):
+        """
+        Add a bs.SpazBot instance to the set.
+        """
+        self._botLists[self._botAddList].append(bot)
+        self._botAddList = (self._botAddList+1)%self._botListCount
+
+# define our built-in characters...
+
+###############  SPAZ   ##################
+t = Appearance("Spaz")
+
+t.colorTexture = "neoSpazColor"
+t.colorMaskTexture = "neoSpazColorMask"
+
+t.iconTexture = "neoSpazIcon"
+t.iconMaskTexture = "neoSpazIconColorMask"
+
+t.headModel = "neoSpazHead"
+t.torsoModel = "neoSpazTorso"
+t.pelvisModel = "neoSpazPelvis"
+t.upperArmModel = "neoSpazUpperArm"
+t.foreArmModel = "neoSpazForeArm"
+t.handModel = "neoSpazHand"
+t.upperLegModel = "neoSpazUpperLeg"
+t.lowerLegModel = "neoSpazLowerLeg"
+t.toesModel = "neoSpazToes"
+
+t.jumpSounds=["spazJump01",
+              "spazJump02",
+              "spazJump03",
+              "spazJump04"]
+t.attackSounds=["spazAttack01",
+                "spazAttack02",
+                "spazAttack03",
+                "spazAttack04"]
+t.impactSounds=["spazImpact01",
+                "spazImpact02",
+                "spazImpact03",
+                "spazImpact04"]
+t.deathSounds=["spazDeath01"]
+t.pickupSounds=["spazPickup01"]
+t.fallSounds=["spazFall01"]
+
+t.style = 'spaz'
+
+
+###############  Zoe   ##################
+t = Appearance("Zoe")
+
+t.colorTexture = "zoeColor"
+t.colorMaskTexture = "zoeColorMask"
+
+t.defaultColor = (0.6, 0.6, 0.6)
+t.defaultHighlight = (0, 1, 0)
+
+t.iconTexture = "zoeIcon"
+t.iconMaskTexture = "zoeIconColorMask"
+
+t.headModel = "zoeHead"
+t.torsoModel = "zoeTorso"
+t.pelvisModel = "zoePelvis"
+t.upperArmModel = "zoeUpperArm"
+t.foreArmModel = "zoeForeArm"
+t.handModel = "zoeHand"
+t.upperLegModel = "zoeUpperLeg"
+t.lowerLegModel = "zoeLowerLeg"
+t.toesModel = "zoeToes"
+
+t.jumpSounds=["zoeJump01",
+              "zoeJump02",
+              "zoeJump03"]
+t.attackSounds=["zoeAttack01",
+                "zoeAttack02",
+                "zoeAttack03",
+                "zoeAttack04"]
+t.impactSounds=["zoeImpact01",
+                "zoeImpact02",
+                "zoeImpact03",
+                "zoeImpact04"]
+t.deathSounds=["zoeDeath01"]
+t.pickupSounds=["zoePickup01"]
+t.fallSounds=["zoeFall01"]
+
+t.style = 'female'
+
+
+###############  Ninja   ##################
+t = Appearance("Snake Shadow")
+
+t.colorTexture = "ninjaColor"
+t.colorMaskTexture = "ninjaColorMask"
+
+t.defaultColor = (1, 1, 1)
+t.defaultHighlight = (0.55, 0.8, 0.55)
+
+t.iconTexture = "ninjaIcon"
+t.iconMaskTexture = "ninjaIconColorMask"
+
+t.headModel = "ninjaHead"
+t.torsoModel = "ninjaTorso"
+t.pelvisModel = "ninjaPelvis"
+t.upperArmModel = "ninjaUpperArm"
+t.foreArmModel = "ninjaForeArm"
+t.handModel = "ninjaHand"
+t.upperLegModel = "ninjaUpperLeg"
+t.lowerLegModel = "ninjaLowerLeg"
+t.toesModel = "ninjaToes"
+
+ninjaAttacks = ['ninjaAttack'+str(i+1)+'' for i in range(7)]
+ninjaHits = ['ninjaHit'+str(i+1)+'' for i in range(8)]
+ninjaJumps = ['ninjaAttack'+str(i+1)+'' for i in range(7)]
+
+t.jumpSounds=ninjaJumps
+t.attackSounds=ninjaAttacks
+t.impactSounds=ninjaHits
+t.deathSounds=["ninjaDeath1"]
+t.pickupSounds=ninjaAttacks
+t.fallSounds=["ninjaFall1"]
+
+t.style = 'ninja'
+
+
+###############  Kronk   ##################
+t = Appearance("Kronk")
+
+t.colorTexture = "kronk"
+t.colorMaskTexture = "kronkColorMask"
+
+t.defaultColor = (0.4, 0.5, 0.4)
+t.defaultHighlight = (1, 0.5, 0.3)
+
+t.iconTexture = "kronkIcon"
+t.iconMaskTexture = "kronkIconColorMask"
+
+t.headModel = "kronkHead"
+t.torsoModel = "kronkTorso"
+t.pelvisModel = "kronkPelvis"
+t.upperArmModel = "kronkUpperArm"
+t.foreArmModel = "kronkForeArm"
+t.handModel = "kronkHand"
+t.upperLegModel = "kronkUpperLeg"
+t.lowerLegModel = "kronkLowerLeg"
+t.toesModel = "kronkToes"
+
+kronkSounds = ["kronk1",
+              "kronk2",
+              "kronk3",
+              "kronk4",
+              "kronk5",
+              "kronk6",
+              "kronk7",
+              "kronk8",
+              "kronk9",
+              "kronk10"]
+t.jumpSounds=kronkSounds
+t.attackSounds=kronkSounds
+t.impactSounds=kronkSounds
+t.deathSounds=["kronkDeath"]
+t.pickupSounds=kronkSounds
+t.fallSounds=["kronkFall"]
+
+t.style = 'kronk'
+
+
+###############  MEL   ##################
+t = Appearance("Mel")
+
+t.colorTexture = "melColor"
+t.colorMaskTexture = "melColorMask"
+
+t.defaultColor = (1, 1, 1)
+t.defaultHighlight = (0.1, 0.6, 0.1)
+
+t.iconTexture = "melIcon"
+t.iconMaskTexture = "melIconColorMask"
+
+t.headModel =     "melHead"
+t.torsoModel =    "melTorso"
+t.pelvisModel = "kronkPelvis"
+t.upperArmModel = "melUpperArm"
+t.foreArmModel =  "melForeArm"
+t.handModel =     "melHand"
+t.upperLegModel = "melUpperLeg"
+t.lowerLegModel = "melLowerLeg"
+t.toesModel =     "melToes"
+
+melSounds = ["mel01",
+              "mel02",
+              "mel03",
+              "mel04",
+              "mel05",
+              "mel06",
+              "mel07",
+              "mel08",
+              "mel09",
+              "mel10"]
+
+t.attackSounds = melSounds
+t.jumpSounds = melSounds
+t.impactSounds = melSounds
+t.deathSounds=["melDeath01"]
+t.pickupSounds = melSounds
+t.fallSounds=["melFall01"]
+
+t.style = 'mel'
+
+
+###############  Jack Morgan   ##################
+
+t = Appearance("Jack Morgan")
+
+t.colorTexture = "jackColor"
+t.colorMaskTexture = "jackColorMask"
+
+t.defaultColor = (1, 0.2, 0.1)
+t.defaultHighlight = (1, 1, 0)
+
+t.iconTexture = "jackIcon"
+t.iconMaskTexture = "jackIconColorMask"
+
+t.headModel =     "jackHead"
+t.torsoModel =    "jackTorso"
+t.pelvisModel = "kronkPelvis"
+t.upperArmModel = "jackUpperArm"
+t.foreArmModel =  "jackForeArm"
+t.handModel =     "jackHand"
+t.upperLegModel = "jackUpperLeg"
+t.lowerLegModel = "jackLowerLeg"
+t.toesModel =     "jackToes"
+
+hitSounds = ["jackHit01",
+             "jackHit02",
+             "jackHit03",
+             "jackHit04",
+             "jackHit05",
+             "jackHit06",
+             "jackHit07"]
+
+sounds = ["jack01",
+          "jack02",
+          "jack03",
+          "jack04",
+          "jack05",
+          "jack06"]
+
+t.attackSounds = sounds
+t.jumpSounds = sounds
+t.impactSounds = hitSounds
+t.deathSounds=["jackDeath01"]
+t.pickupSounds = sounds
+t.fallSounds=["jackFall01"]
+
+t.style = 'pirate'
+
+
+###############  SANTA   ##################
+
+t = Appearance("Santa Claus")
+
+t.colorTexture = "santaColor"
+t.colorMaskTexture = "santaColorMask"
+
+t.defaultColor = (1, 0, 0)
+t.defaultHighlight = (1, 1, 1)
+
+t.iconTexture = "santaIcon"
+t.iconMaskTexture = "santaIconColorMask"
+
+t.headModel =     "santaHead"
+t.torsoModel =    "santaTorso"
+t.pelvisModel = "kronkPelvis"
+t.upperArmModel = "santaUpperArm"
+t.foreArmModel =  "santaForeArm"
+t.handModel =     "santaHand"
+t.upperLegModel = "santaUpperLeg"
+t.lowerLegModel = "santaLowerLeg"
+t.toesModel =     "santaToes"
+
+hitSounds = ['santaHit01', 'santaHit02', 'santaHit03', 'santaHit04']
+sounds = ['santa01', 'santa02', 'santa03', 'santa04', 'santa05']
+
+t.attackSounds = sounds
+t.jumpSounds = sounds
+t.impactSounds = hitSounds
+t.deathSounds=["santaDeath"]
+t.pickupSounds = sounds
+t.fallSounds=["santaFall"]
+
+t.style = 'santa'
+
+###############  FROSTY   ##################
+
+t = Appearance("Frosty")
+
+t.colorTexture = "frostyColor"
+t.colorMaskTexture = "frostyColorMask"
+
+t.defaultColor = (0.5, 0.5, 1)
+t.defaultHighlight = (1, 0.5, 0)
+
+t.iconTexture = "frostyIcon"
+t.iconMaskTexture = "frostyIconColorMask"
+
+t.headModel =     "frostyHead"
+t.torsoModel =    "frostyTorso"
+t.pelvisModel = "frostyPelvis"
+t.upperArmModel = "frostyUpperArm"
+t.foreArmModel =  "frostyForeArm"
+t.handModel =     "frostyHand"
+t.upperLegModel = "frostyUpperLeg"
+t.lowerLegModel = "frostyLowerLeg"
+t.toesModel =     "frostyToes"
+
+frostySounds = ['frosty01', 'frosty02', 'frosty03', 'frosty04', 'frosty05']
+frostyHitSounds = ['frostyHit01', 'frostyHit02', 'frostyHit03']
+
+t.attackSounds = frostySounds
+t.jumpSounds = frostySounds
+t.impactSounds = frostyHitSounds
+t.deathSounds=["frostyDeath"]
+t.pickupSounds = frostySounds
+t.fallSounds=["frostyFall"]
+
+t.style = 'frosty'
+
+###############  BONES  ##################
+
+t = Appearance("Bones")
+
+t.colorTexture = "bonesColor"
+t.colorMaskTexture = "bonesColorMask"
+
+t.defaultColor = (0.6, 0.9, 1)
+t.defaultHighlight = (0.6, 0.9, 1)
+
+t.iconTexture = "bonesIcon"
+t.iconMaskTexture = "bonesIconColorMask"
+
+t.headModel =     "bonesHead"
+t.torsoModel =    "bonesTorso"
+t.pelvisModel =   "bonesPelvis"
+t.upperArmModel = "bonesUpperArm"
+t.foreArmModel =  "bonesForeArm"
+t.handModel =     "bonesHand"
+t.upperLegModel = "bonesUpperLeg"
+t.lowerLegModel = "bonesLowerLeg"
+t.toesModel =     "bonesToes"
+
+bonesSounds =    ['bones1', 'bones2', 'bones3']
+bonesHitSounds = ['bones1', 'bones2', 'bones3']
+
+t.attackSounds = bonesSounds
+t.jumpSounds = bonesSounds
+t.impactSounds = bonesHitSounds
+t.deathSounds=["bonesDeath"]
+t.pickupSounds = bonesSounds
+t.fallSounds=["bonesFall"]
+
+t.style = 'bones'
+
+# bear ###################################
+
+t = Appearance("Bernard")
+
+t.colorTexture = "bearColor"
+t.colorMaskTexture = "bearColorMask"
+
+t.defaultColor = (0.7, 0.5, 0.0)
+#t.defaultHighlight = (0.6, 0.5, 0.8)
+
+t.iconTexture = "bearIcon"
+t.iconMaskTexture = "bearIconColorMask"
+
+t.headModel =     "bearHead"
+t.torsoModel =    "bearTorso"
+t.pelvisModel =   "bearPelvis"
+t.upperArmModel = "bearUpperArm"
+t.foreArmModel =  "bearForeArm"
+t.handModel =     "bearHand"
+t.upperLegModel = "bearUpperLeg"
+t.lowerLegModel = "bearLowerLeg"
+t.toesModel =     "bearToes"
+
+bearSounds =    ['bear1', 'bear2', 'bear3', 'bear4']
+bearHitSounds = ['bearHit1', 'bearHit2']
+
+t.attackSounds = bearSounds
+t.jumpSounds = bearSounds
+t.impactSounds = bearHitSounds
+t.deathSounds=["bearDeath"]
+t.pickupSounds = bearSounds
+t.fallSounds=["bearFall"]
+
+t.style = 'bear'
+
+# Penguin ###################################
+
+t = Appearance("Pascal")
+
+t.colorTexture = "penguinColor"
+t.colorMaskTexture = "penguinColorMask"
+
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+
+t.iconTexture = "penguinIcon"
+t.iconMaskTexture = "penguinIconColorMask"
+
+t.headModel =     "penguinHead"
+t.torsoModel =    "penguinTorso"
+t.pelvisModel =   "penguinPelvis"
+t.upperArmModel = "penguinUpperArm"
+t.foreArmModel =  "penguinForeArm"
+t.handModel =     "penguinHand"
+t.upperLegModel = "penguinUpperLeg"
+t.lowerLegModel = "penguinLowerLeg"
+t.toesModel =     "penguinToes"
+
+penguinSounds =    ['penguin1', 'penguin2', 'penguin3', 'penguin4']
+penguinHitSounds = ['penguinHit1', 'penguinHit2']
+
+t.attackSounds = penguinSounds
+t.jumpSounds = penguinSounds
+t.impactSounds = penguinHitSounds
+t.deathSounds=["penguinDeath"]
+t.pickupSounds = penguinSounds
+t.fallSounds=["penguinFall"]
+
+t.style = 'penguin'
+
+
+# Ali ###################################
+t = Appearance("Taobao Mascot")
+t.colorTexture = "aliColor"
+t.colorMaskTexture = "aliColorMask"
+t.defaultColor = (1, 0.5, 0)
+t.defaultHighlight = (1, 1, 1)
+t.iconTexture = "aliIcon"
+t.iconMaskTexture = "aliIconColorMask"
+t.headModel =     "aliHead"
+t.torsoModel =    "aliTorso"
+t.pelvisModel =   "aliPelvis"
+t.upperArmModel = "aliUpperArm"
+t.foreArmModel =  "aliForeArm"
+t.handModel =     "aliHand"
+t.upperLegModel = "aliUpperLeg"
+t.lowerLegModel = "aliLowerLeg"
+t.toesModel =     "aliToes"
+aliSounds =    ['ali1', 'ali2', 'ali3', 'ali4']
+aliHitSounds = ['aliHit1', 'aliHit2']
+t.attackSounds = aliSounds
+t.jumpSounds = aliSounds
+t.impactSounds = aliHitSounds
+t.deathSounds=["aliDeath"]
+t.pickupSounds = aliSounds
+t.fallSounds=["aliFall"]
+t.style = 'ali'
+
+# cyborg ###################################
+t = Appearance("B-9000")
+t.colorTexture = "cyborgColor"
+t.colorMaskTexture = "cyborgColorMask"
+t.defaultColor = (0.5, 0.5, 0.5)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "cyborgIcon"
+t.iconMaskTexture = "cyborgIconColorMask"
+t.headModel =     "cyborgHead"
+t.torsoModel =    "cyborgTorso"
+t.pelvisModel =   "cyborgPelvis"
+t.upperArmModel = "cyborgUpperArm"
+t.foreArmModel =  "cyborgForeArm"
+t.handModel =     "cyborgHand"
+t.upperLegModel = "cyborgUpperLeg"
+t.lowerLegModel = "cyborgLowerLeg"
+t.toesModel =     "cyborgToes"
+cyborgSounds =    ['cyborg1', 'cyborg2', 'cyborg3', 'cyborg4']
+cyborgHitSounds = ['cyborgHit1', 'cyborgHit2']
+t.attackSounds = cyborgSounds
+t.jumpSounds = cyborgSounds
+t.impactSounds = cyborgHitSounds
+t.deathSounds=["cyborgDeath"]
+t.pickupSounds = cyborgSounds
+t.fallSounds=["cyborgFall"]
+t.style = 'cyborg'
+
+# Agent ###################################
+t = Appearance("Agent Johnson")
+t.colorTexture = "agentColor"
+t.colorMaskTexture = "agentColorMask"
+t.defaultColor = (0.3, 0.3, 0.33)
+t.defaultHighlight = (1, 0.5, 0.3)
+t.iconTexture = "agentIcon"
+t.iconMaskTexture = "agentIconColorMask"
+t.headModel =     "agentHead"
+t.torsoModel =    "agentTorso"
+t.pelvisModel =   "agentPelvis"
+t.upperArmModel = "agentUpperArm"
+t.foreArmModel =  "agentForeArm"
+t.handModel =     "agentHand"
+t.upperLegModel = "agentUpperLeg"
+t.lowerLegModel = "agentLowerLeg"
+t.toesModel =     "agentToes"
+agentSounds =    ['agent1', 'agent2', 'agent3', 'agent4']
+agentHitSounds = ['agentHit1', 'agentHit2']
+t.attackSounds = agentSounds
+t.jumpSounds = agentSounds
+t.impactSounds = agentHitSounds
+t.deathSounds=["agentDeath"]
+t.pickupSounds = agentSounds
+t.fallSounds=["agentFall"]
+t.style = 'agent'
+
+# Jumpsuit ###################################
+t = Appearance("Lee")
+t.colorTexture = "jumpsuitColor"
+t.colorMaskTexture = "jumpsuitColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "jumpsuitIcon"
+t.iconMaskTexture = "jumpsuitIconColorMask"
+t.headModel =     "jumpsuitHead"
+t.torsoModel =    "jumpsuitTorso"
+t.pelvisModel =   "jumpsuitPelvis"
+t.upperArmModel = "jumpsuitUpperArm"
+t.foreArmModel =  "jumpsuitForeArm"
+t.handModel =     "jumpsuitHand"
+t.upperLegModel = "jumpsuitUpperLeg"
+t.lowerLegModel = "jumpsuitLowerLeg"
+t.toesModel =     "jumpsuitToes"
+jumpsuitSounds = ['jumpsuit1', 'jumpsuit2', 'jumpsuit3', 'jumpsuit4']
+jumpsuitHitSounds = ['jumpsuitHit1', 'jumpsuitHit2']
+t.attackSounds = jumpsuitSounds
+t.jumpSounds = jumpsuitSounds
+t.impactSounds = jumpsuitHitSounds
+t.deathSounds=["jumpsuitDeath"]
+t.pickupSounds = jumpsuitSounds
+t.fallSounds=["jumpsuitFall"]
+t.style = 'spaz'
+
+# ActionHero ###################################
+t = Appearance("Todd McBurton")
+t.colorTexture = "actionHeroColor"
+t.colorMaskTexture = "actionHeroColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "actionHeroIcon"
+t.iconMaskTexture = "actionHeroIconColorMask"
+t.headModel =     "actionHeroHead"
+t.torsoModel =    "actionHeroTorso"
+t.pelvisModel =   "actionHeroPelvis"
+t.upperArmModel = "actionHeroUpperArm"
+t.foreArmModel =  "actionHeroForeArm"
+t.handModel =     "actionHeroHand"
+t.upperLegModel = "actionHeroUpperLeg"
+t.lowerLegModel = "actionHeroLowerLeg"
+t.toesModel =     "actionHeroToes"
+actionHeroSounds = ['actionHero1', 'actionHero2', 'actionHero3', 'actionHero4']
+actionHeroHitSounds = ['actionHeroHit1', 'actionHeroHit2']
+t.attackSounds = actionHeroSounds
+t.jumpSounds = actionHeroSounds
+t.impactSounds = actionHeroHitSounds
+t.deathSounds=["actionHeroDeath"]
+t.pickupSounds = actionHeroSounds
+t.fallSounds=["actionHeroFall"]
+t.style = 'spaz'
+
+# Assassin ###################################
+t = Appearance("Zola")
+t.colorTexture = "assassinColor"
+t.colorMaskTexture = "assassinColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "assassinIcon"
+t.iconMaskTexture = "assassinIconColorMask"
+t.headModel =     "assassinHead"
+t.torsoModel =    "assassinTorso"
+t.pelvisModel =   "assassinPelvis"
+t.upperArmModel = "assassinUpperArm"
+t.foreArmModel =  "assassinForeArm"
+t.handModel =     "assassinHand"
+t.upperLegModel = "assassinUpperLeg"
+t.lowerLegModel = "assassinLowerLeg"
+t.toesModel =     "assassinToes"
+assassinSounds = ['assassin1', 'assassin2', 'assassin3', 'assassin4']
+assassinHitSounds = ['assassinHit1', 'assassinHit2']
+t.attackSounds = assassinSounds
+t.jumpSounds = assassinSounds
+t.impactSounds = assassinHitSounds
+t.deathSounds=["assassinDeath"]
+t.pickupSounds = assassinSounds
+t.fallSounds=["assassinFall"]
+t.style = 'spaz'
+
+# Wizard ###################################
+t = Appearance("Grumbledorf")
+t.colorTexture = "wizardColor"
+t.colorMaskTexture = "wizardColorMask"
+t.defaultColor = (0.2, 0.4, 1.0)
+t.defaultHighlight = (0.06, 0.15, 0.4)
+t.iconTexture = "wizardIcon"
+t.iconMaskTexture = "wizardIconColorMask"
+t.headModel =     "wizardHead"
+t.torsoModel =    "wizardTorso"
+t.pelvisModel =   "wizardPelvis"
+t.upperArmModel = "wizardUpperArm"
+t.foreArmModel =  "wizardForeArm"
+t.handModel =     "wizardHand"
+t.upperLegModel = "wizardUpperLeg"
+t.lowerLegModel = "wizardLowerLeg"
+t.toesModel =     "wizardToes"
+wizardSounds =    ['wizard1', 'wizard2', 'wizard3', 'wizard4']
+wizardHitSounds = ['wizardHit1', 'wizardHit2']
+t.attackSounds = wizardSounds
+t.jumpSounds = wizardSounds
+t.impactSounds = wizardHitSounds
+t.deathSounds=["wizardDeath"]
+t.pickupSounds = wizardSounds
+t.fallSounds=["wizardFall"]
+t.style = 'spaz'
+
+# Cowboy ###################################
+t = Appearance("Butch")
+t.colorTexture = "cowboyColor"
+t.colorMaskTexture = "cowboyColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "cowboyIcon"
+t.iconMaskTexture = "cowboyIconColorMask"
+t.headModel =     "cowboyHead"
+t.torsoModel =    "cowboyTorso"
+t.pelvisModel =   "cowboyPelvis"
+t.upperArmModel = "cowboyUpperArm"
+t.foreArmModel =  "cowboyForeArm"
+t.handModel =     "cowboyHand"
+t.upperLegModel = "cowboyUpperLeg"
+t.lowerLegModel = "cowboyLowerLeg"
+t.toesModel =     "cowboyToes"
+cowboySounds =    ['cowboy1', 'cowboy2', 'cowboy3', 'cowboy4']
+cowboyHitSounds = ['cowboyHit1', 'cowboyHit2']
+t.attackSounds = cowboySounds
+t.jumpSounds = cowboySounds
+t.impactSounds = cowboyHitSounds
+t.deathSounds=["cowboyDeath"]
+t.pickupSounds = cowboySounds
+t.fallSounds=["cowboyFall"]
+t.style = 'spaz'
+
+# Witch ###################################
+t = Appearance("Witch")
+t.colorTexture = "witchColor"
+t.colorMaskTexture = "witchColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "witchIcon"
+t.iconMaskTexture = "witchIconColorMask"
+t.headModel =     "witchHead"
+t.torsoModel =    "witchTorso"
+t.pelvisModel =   "witchPelvis"
+t.upperArmModel = "witchUpperArm"
+t.foreArmModel =  "witchForeArm"
+t.handModel =     "witchHand"
+t.upperLegModel = "witchUpperLeg"
+t.lowerLegModel = "witchLowerLeg"
+t.toesModel =     "witchToes"
+witchSounds =    ['witch1', 'witch2', 'witch3', 'witch4']
+witchHitSounds = ['witchHit1', 'witchHit2']
+t.attackSounds = witchSounds
+t.jumpSounds = witchSounds
+t.impactSounds = witchHitSounds
+t.deathSounds=["witchDeath"]
+t.pickupSounds = witchSounds
+t.fallSounds=["witchFall"]
+t.style = 'spaz'
+
+# Warrior ###################################
+t = Appearance("Warrior")
+t.colorTexture = "warriorColor"
+t.colorMaskTexture = "warriorColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "warriorIcon"
+t.iconMaskTexture = "warriorIconColorMask"
+t.headModel =     "warriorHead"
+t.torsoModel =    "warriorTorso"
+t.pelvisModel =   "warriorPelvis"
+t.upperArmModel = "warriorUpperArm"
+t.foreArmModel =  "warriorForeArm"
+t.handModel =     "warriorHand"
+t.upperLegModel = "warriorUpperLeg"
+t.lowerLegModel = "warriorLowerLeg"
+t.toesModel =     "warriorToes"
+warriorSounds =    ['warrior1', 'warrior2', 'warrior3', 'warrior4']
+warriorHitSounds = ['warriorHit1', 'warriorHit2']
+t.attackSounds = warriorSounds
+t.jumpSounds = warriorSounds
+t.impactSounds = warriorHitSounds
+t.deathSounds=["warriorDeath"]
+t.pickupSounds = warriorSounds
+t.fallSounds=["warriorFall"]
+t.style = 'spaz'
+
+# Superhero ###################################
+t = Appearance("Middle-Man")
+t.colorTexture = "superheroColor"
+t.colorMaskTexture = "superheroColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "superheroIcon"
+t.iconMaskTexture = "superheroIconColorMask"
+t.headModel =     "superheroHead"
+t.torsoModel =    "superheroTorso"
+t.pelvisModel =   "superheroPelvis"
+t.upperArmModel = "superheroUpperArm"
+t.foreArmModel =  "superheroForeArm"
+t.handModel =     "superheroHand"
+t.upperLegModel = "superheroUpperLeg"
+t.lowerLegModel = "superheroLowerLeg"
+t.toesModel =     "superheroToes"
+superheroSounds =    ['superhero1', 'superhero2', 'superhero3', 'superhero4']
+superheroHitSounds = ['superheroHit1', 'superheroHit2']
+t.attackSounds = superheroSounds
+t.jumpSounds = superheroSounds
+t.impactSounds = superheroHitSounds
+t.deathSounds=["superheroDeath"]
+t.pickupSounds = superheroSounds
+t.fallSounds=["superheroFall"]
+t.style = 'spaz'
+
+# Alien ###################################
+t = Appearance("Alien")
+t.colorTexture = "alienColor"
+t.colorMaskTexture = "alienColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "alienIcon"
+t.iconMaskTexture = "alienIconColorMask"
+t.headModel =     "alienHead"
+t.torsoModel =    "alienTorso"
+t.pelvisModel =   "alienPelvis"
+t.upperArmModel = "alienUpperArm"
+t.foreArmModel =  "alienForeArm"
+t.handModel =     "alienHand"
+t.upperLegModel = "alienUpperLeg"
+t.lowerLegModel = "alienLowerLeg"
+t.toesModel =     "alienToes"
+alienSounds =    ['alien1', 'alien2', 'alien3', 'alien4']
+alienHitSounds = ['alienHit1', 'alienHit2']
+t.attackSounds = alienSounds
+t.jumpSounds = alienSounds
+t.impactSounds = alienHitSounds
+t.deathSounds=["alienDeath"]
+t.pickupSounds = alienSounds
+t.fallSounds=["alienFall"]
+t.style = 'spaz'
+
+# OldLady ###################################
+t = Appearance("OldLady")
+t.colorTexture = "oldLadyColor"
+t.colorMaskTexture = "oldLadyColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "oldLadyIcon"
+t.iconMaskTexture = "oldLadyIconColorMask"
+t.headModel =     "oldLadyHead"
+t.torsoModel =    "oldLadyTorso"
+t.pelvisModel =   "oldLadyPelvis"
+t.upperArmModel = "oldLadyUpperArm"
+t.foreArmModel =  "oldLadyForeArm"
+t.handModel =     "oldLadyHand"
+t.upperLegModel = "oldLadyUpperLeg"
+t.lowerLegModel = "oldLadyLowerLeg"
+t.toesModel =     "oldLadyToes"
+oldLadySounds =    ['oldLady1', 'oldLady2', 'oldLady3', 'oldLady4']
+oldLadyHitSounds = ['oldLadyHit1', 'oldLadyHit2']
+t.attackSounds = oldLadySounds
+t.jumpSounds = oldLadySounds
+t.impactSounds = oldLadyHitSounds
+t.deathSounds=["oldLadyDeath"]
+t.pickupSounds = oldLadySounds
+t.fallSounds=["oldLadyFall"]
+t.style = 'spaz'
+
+# Gladiator ###################################
+t = Appearance("Gladiator")
+t.colorTexture = "gladiatorColor"
+t.colorMaskTexture = "gladiatorColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "gladiatorIcon"
+t.iconMaskTexture = "gladiatorIconColorMask"
+t.headModel =     "gladiatorHead"
+t.torsoModel =    "gladiatorTorso"
+t.pelvisModel =   "gladiatorPelvis"
+t.upperArmModel = "gladiatorUpperArm"
+t.foreArmModel =  "gladiatorForeArm"
+t.handModel =     "gladiatorHand"
+t.upperLegModel = "gladiatorUpperLeg"
+t.lowerLegModel = "gladiatorLowerLeg"
+t.toesModel =     "gladiatorToes"
+gladiatorSounds =    ['gladiator1', 'gladiator2', 'gladiator3', 'gladiator4']
+gladiatorHitSounds = ['gladiatorHit1', 'gladiatorHit2']
+t.attackSounds = gladiatorSounds
+t.jumpSounds = gladiatorSounds
+t.impactSounds = gladiatorHitSounds
+t.deathSounds=["gladiatorDeath"]
+t.pickupSounds = gladiatorSounds
+t.fallSounds=["gladiatorFall"]
+t.style = 'spaz'
+
+# Wrestler ###################################
+t = Appearance("Wrestler")
+t.colorTexture = "wrestlerColor"
+t.colorMaskTexture = "wrestlerColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "wrestlerIcon"
+t.iconMaskTexture = "wrestlerIconColorMask"
+t.headModel =     "wrestlerHead"
+t.torsoModel =    "wrestlerTorso"
+t.pelvisModel =   "wrestlerPelvis"
+t.upperArmModel = "wrestlerUpperArm"
+t.foreArmModel =  "wrestlerForeArm"
+t.handModel =     "wrestlerHand"
+t.upperLegModel = "wrestlerUpperLeg"
+t.lowerLegModel = "wrestlerLowerLeg"
+t.toesModel =     "wrestlerToes"
+wrestlerSounds =    ['wrestler1', 'wrestler2', 'wrestler3', 'wrestler4']
+wrestlerHitSounds = ['wrestlerHit1', 'wrestlerHit2']
+t.attackSounds = wrestlerSounds
+t.jumpSounds = wrestlerSounds
+t.impactSounds = wrestlerHitSounds
+t.deathSounds=["wrestlerDeath"]
+t.pickupSounds = wrestlerSounds
+t.fallSounds=["wrestlerFall"]
+t.style = 'spaz'
+
+# OperaSinger ###################################
+t = Appearance("Gretel")
+t.colorTexture = "operaSingerColor"
+t.colorMaskTexture = "operaSingerColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "operaSingerIcon"
+t.iconMaskTexture = "operaSingerIconColorMask"
+t.headModel =     "operaSingerHead"
+t.torsoModel =    "operaSingerTorso"
+t.pelvisModel =   "operaSingerPelvis"
+t.upperArmModel = "operaSingerUpperArm"
+t.foreArmModel =  "operaSingerForeArm"
+t.handModel =     "operaSingerHand"
+t.upperLegModel = "operaSingerUpperLeg"
+t.lowerLegModel = "operaSingerLowerLeg"
+t.toesModel =     "operaSingerToes"
+operaSingerSounds =    ['operaSinger1', 'operaSinger2',
+                        'operaSinger3', 'operaSinger4']
+operaSingerHitSounds = ['operaSingerHit1', 'operaSingerHit2']
+t.attackSounds = operaSingerSounds
+t.jumpSounds = operaSingerSounds
+t.impactSounds = operaSingerHitSounds
+t.deathSounds=["operaSingerDeath"]
+t.pickupSounds = operaSingerSounds
+t.fallSounds=["operaSingerFall"]
+t.style = 'spaz'
+
+# Pixie ###################################
+t = Appearance("Pixel")
+t.colorTexture = "pixieColor"
+t.colorMaskTexture = "pixieColorMask"
+t.defaultColor = (0, 1, 0.7)
+t.defaultHighlight = (0.65, 0.35, 0.75)
+t.iconTexture = "pixieIcon"
+t.iconMaskTexture = "pixieIconColorMask"
+t.headModel =     "pixieHead"
+t.torsoModel =    "pixieTorso"
+t.pelvisModel =   "pixiePelvis"
+t.upperArmModel = "pixieUpperArm"
+t.foreArmModel =  "pixieForeArm"
+t.handModel =     "pixieHand"
+t.upperLegModel = "pixieUpperLeg"
+t.lowerLegModel = "pixieLowerLeg"
+t.toesModel =     "pixieToes"
+pixieSounds =    ['pixie1', 'pixie2', 'pixie3', 'pixie4']
+pixieHitSounds = ['pixieHit1', 'pixieHit2']
+t.attackSounds = pixieSounds
+t.jumpSounds = pixieSounds
+t.impactSounds = pixieHitSounds
+t.deathSounds=["pixieDeath"]
+t.pickupSounds = pixieSounds
+t.fallSounds=["pixieFall"]
+t.style = 'pixie'
+
+# Robot ###################################
+t = Appearance("Robot")
+t.colorTexture = "robotColor"
+t.colorMaskTexture = "robotColorMask"
+t.defaultColor = (0.3, 0.5, 0.8)
+t.defaultHighlight = (1, 0, 0)
+t.iconTexture = "robotIcon"
+t.iconMaskTexture = "robotIconColorMask"
+t.headModel =     "robotHead"
+t.torsoModel =    "robotTorso"
+t.pelvisModel =   "robotPelvis"
+t.upperArmModel = "robotUpperArm"
+t.foreArmModel =  "robotForeArm"
+t.handModel =     "robotHand"
+t.upperLegModel = "robotUpperLeg"
+t.lowerLegModel = "robotLowerLeg"
+t.toesModel =     "robotToes"
+robotSounds =    ['robot1', 'robot2', 'robot3', 'robot4']
+robotHitSounds = ['robotHit1', 'robotHit2']
+t.attackSounds = robotSounds
+t.jumpSounds = robotSounds
+t.impactSounds = robotHitSounds
+t.deathSounds=["robotDeath"]
+t.pickupSounds = robotSounds
+t.fallSounds=["robotFall"]
+t.style = 'spaz'
+
+# Bunny ###################################
+t = Appearance("Easter Bunny")
+t.colorTexture = "bunnyColor"
+t.colorMaskTexture = "bunnyColorMask"
+t.defaultColor = (1, 1, 1)
+t.defaultHighlight = (1, 0.5, 0.5)
+t.iconTexture = "bunnyIcon"
+t.iconMaskTexture = "bunnyIconColorMask"
+t.headModel =     "bunnyHead"
+t.torsoModel =    "bunnyTorso"
+t.pelvisModel =   "bunnyPelvis"
+t.upperArmModel = "bunnyUpperArm"
+t.foreArmModel =  "bunnyForeArm"
+t.handModel =     "bunnyHand"
+t.upperLegModel = "bunnyUpperLeg"
+t.lowerLegModel = "bunnyLowerLeg"
+t.toesModel =     "bunnyToes"
+bunnySounds =    ['bunny1', 'bunny2', 'bunny3', 'bunny4']
+bunnyHitSounds = ['bunnyHit1', 'bunnyHit2']
+t.attackSounds = bunnySounds
+t.jumpSounds = ['bunnyJump']
+t.impactSounds = bunnyHitSounds
+t.deathSounds=["bunnyDeath"]
+t.pickupSounds = bunnySounds
+t.fallSounds=["bunnyFall"]
+t.style = 'bunny'
diff --git a/scripts/bsTeamGame.py b/scripts/bsTeamGame.py
new file mode 100644
index 0000000000000000000000000000000000000000..294594e0a49e9fcb1500fe5714015f761d40d33d
--- /dev/null
+++ b/scripts/bsTeamGame.py
@@ -0,0 +1,1455 @@
+import bs
+import bsGame
+import bsUtils
+import random
+import weakref
+import copy
+import bsTutorial
+import bsInternal
+
+gDefaultTeamColors = ((0.1, 0.25, 1.0), (1.0, 0.25, 0.2))
+gDefaultTeamNames = ("Blue", "Red")
+
+gTeamSeriesLength = 7
+gFFASeriesLength = 24
+
+
+class TeamGameResults(object):
+    """
+    category: Game Flow Classes
+
+    Results for a completed bs.TeamGameActivity.
+    Upon completion, a game should fill one of these out and pass it to its
+    bs.Activity.end() call.
+    """
+
+    def __init__(self):
+        """
+        Instantiate a results instance.
+        """
+
+        self._gameSet = False
+        self._scores = {}
+
+    def _setGame(self, game):
+        if self._gameSet:
+            raise Exception("game being set twice for TeamGameResults")
+        self._gameSet = True
+        self._teams = [weakref.ref(team) for team in game.teams]
+        scoreInfo = game.getResolvedScoreInfo()
+        self._playerInfo = copy.deepcopy(game.initialPlayerInfo)
+        self._lowerIsBetter = scoreInfo['lowerIsBetter']
+        self._scoreName = scoreInfo['scoreName']
+        self._noneIsWinner = scoreInfo['noneIsWinner']
+        self._scoreType = scoreInfo['scoreType']
+
+    def setTeamScore(self, team, score):
+        """
+        Set the score for a given bs.Team.
+        This can be a number or None.
+        (see the noneIsWinner arg in the constructor)
+        """
+        self._scores[team.getID()] = (weakref.ref(team), score)
+
+    def _getTeamScore(self, team):
+        'Return the score for a given bs.Team'
+        for score in self._scores.values():
+            if score[0]() is team:
+                return score[1]
+        # if we have no score, None is assumed
+        return None
+
+    def _getTeams(self):
+        'Return all bs.Teams in the results.'
+        if not self._gameSet:
+            raise Exception("cant get this until game is set")
+        teams = []
+        for teamRef in self._teams:
+            team = teamRef()
+            if team is not None:
+                teams.append(team)
+        return teams
+
+    def _hasScoreForTeam(self, team):
+        'Return whether there is a score for a given bs.Team'
+        for score in self._scores.values():
+            if score[0]() is team:
+                return True
+        return False
+
+    def _getTeamScoreStr(self, team):
+        """
+        Return a score for a bs.Team as a string,
+        properly formatted for the score type.
+        """
+        if not self._gameSet:
+            raise Exception("cant get this until game is set")
+        for score in self._scores.values():
+            if score[0]() is team:
+                if score[1] is None:
+                    return '-'
+                if self._scoreType == 'seconds':
+                    return bsUtils.getTimeString(score[1]*1000, centi=False)
+                elif self._scoreType == 'milliseconds':
+                    return bsUtils.getTimeString(score[1], centi=True)
+                else:
+                    return str(score[1])
+        return '-'
+
+    def _getScoreName(self):
+        'Return the name associated with scores (\'points\', etc)'
+        if not self._gameSet:
+            raise Exception("cant get this until game is set")
+        return self._scoreName
+
+    def _getLowerIsBetter(self):
+        'Return whether lower scores are better'
+        if not self._gameSet:
+            raise Exception("cant get this until game is set")
+        return self._lowerIsBetter
+
+    def _getWinningTeam(self):
+        'Return the winning bs.Team if there is one; None othewise.'
+        if not self._gameSet:
+            raise Exception("cant get winners until game is set")
+        winners = self._getWinners()
+        if len(winners) > 0 and len(winners[0]['teams']) == 1:
+            return winners[0]['teams'][0]
+        else:
+            return None
+
+    def _getWinners(self):
+        'Return an ordered list of dicts containing score and teams'
+        if not self._gameSet:
+            raise Exception("cant get winners until game is set")
+        winners = {}
+        # filter out any dead teams
+        scores = [score for score in self._scores.values()
+                  if score[0]() is not None and score[1] is not None]
+        for score in scores:
+            try:
+                s = winners[score[1]]
+            except Exception:
+                s = winners[score[1]] = []
+            s.append(score[0]())
+        results = winners.items()
+        results.sort(reverse=not self._lowerIsBetter)
+
+        # tack a group with all our 'None' scores onto the end
+        noneTeams = [score[0]() for score in self._scores.values()
+                     if score[0]() is not None and score[1] is None]
+        if len(noneTeams) > 0:
+            nones = [(None, noneTeams)]
+            if self._noneIsWinner:
+                results = nones + results
+            else:
+                results = results + nones
+        return [{'score': r[0], 'teams':r[1]} for r in results]
+
+
+class ShuffleList(object):
+    """
+    shuffles a set of games with some smarts
+    to avoid repeats in maps or game types
+    """
+
+    def __init__(self, items, shuffle=True):
+        self.sourceList = items
+        self.shuffle = shuffle
+        self.shuffleList = []
+        self.lastGotten = None
+
+    def pullNext(self):
+
+        # refill our list if its empty
+        if len(self.shuffleList) == 0:
+            self.shuffleList = list(self.sourceList)
+
+        # ok now find an index we should pull
+        index = 0
+
+        if self.shuffle:
+            for i in range(4):
+                index = random.randrange(0, len(self.shuffleList))
+                testObj = self.shuffleList[index]
+                # if the new one is the same map or game-type as the previous,
+                # lets try to keep looking..
+                if len(self.shuffleList) > 1 and self.lastGotten is not None:
+                    if testObj['settings']['map'] == \
+                       self.lastGotten['settings']['map']:
+                        continue
+                    if testObj['type'] == self.lastGotten['type']:
+                        continue
+                # sufficiently different.. lets go with it
+                break
+
+        obj = self.shuffleList.pop(index)
+        self.lastGotten = obj
+        return obj
+
+
+class TeamsScoreScreenActivity(bsGame.ScoreScreenActivity):
+
+    def __init__(self, settings={}):
+        bsGame.ScoreScreenActivity.__init__(self, settings=settings)
+        self._scoreDisplaySound = bs.getSound("scoreHit01")
+        self._scoreDisplaySoundSmall = bs.getSound("scoreHit02")
+
+    def onBegin(self, showUpNext=True, customContinueMessage=None):
+        bsGame.ScoreScreenActivity.onBegin(
+            self, customContinueMessage=customContinueMessage)
+        if showUpNext:
+            t = bs.Lstr(value='${A}   ${B}',
+                        subs=[('${A}', bs.Lstr(
+                            resource='upNextText',
+                            subs=[('${COUNT}',
+                                   str(self.getSession().getGameNumber()+1))])
+                        ),('${B}', self.getSession().getNextGameDescription())])
+            bsUtils.Text(t,
+                         maxWidth=900,
+                         hAttach='center',
+                         vAttach='bottom',
+                         hAlign='center',
+                         vAlign='center',
+                         position=(0, 53),
+                         flash=False,
+                         color=(0.3, 0.3, 0.35, 1.0),
+                         transition='fadeIn',
+                         transitionDelay=2000).autoRetain()
+
+    def showPlayerScores(
+            self, delay=2500, results=None, scale=1.0, xOffset=0, yOffset=0):
+        tsVOffset = 150.0+yOffset
+        tsHOffs = 80.0+xOffset
+        tDelay = delay
+        spacing = 40
+
+        isFreeForAll = isinstance(self.getSession(), bs.FreeForAllSession)
+
+        def _getPlayerScore(player):
+            if isFreeForAll and results is not None:
+                return results._getTeamScore(player.getTeam())
+            else:
+                return player.accumScore
+
+        def _getPlayerScoreStr(player):
+            if isFreeForAll and results is not None:
+                return results._getTeamScoreStr(player.getTeam())
+            else:
+                return str(player.accumScore)
+
+        # getValidPlayers() can return players that are no longer in the game..
+        # if we're using results we have to filter
+        # those out (since they're not in results and that's where we pull their
+        # scores from)
+        if results is not None:
+            playersSorted = []
+            validPlayers = self.scoreSet.getValidPlayers().items()
+
+            def _getPlayerScoreSetEntry(player):
+                for p in validPlayers:
+                    if p[1].getPlayer() is player:
+                        return p[1]
+                return None
+
+            # results is already sorted; just convert it into a list of
+            # score-set-entries
+            for winner in results._getWinners():
+                for team in winner['teams']:
+                    if len(team.players) == 1:
+                        playerEntry = _getPlayerScoreSetEntry(team.players[0])
+                        if playerEntry is not None:
+                            playersSorted.append(playerEntry)
+        else:
+            playersSorted = [
+                [_getPlayerScore(p),
+                 name, p] for name, p in
+                self.scoreSet.getValidPlayers().items()]
+            playersSorted.sort(
+                reverse=(
+                    results
+                    is None or
+                    not results._getLowerIsBetter()))
+            # just want living player entries
+            playersSorted = [p[2] for p in playersSorted if p[2]]
+
+        vOffs = -140.0 + spacing*len(playersSorted)*0.5
+
+        def _txt(
+                xOffs, yOffs, text, hAlign='right', extraScale=1.0,
+                maxWidth=120.0):
+            bsUtils.Text(text, color=(0.5, 0.5, 0.6, 0.5),
+                         position=(tsHOffs + xOffs * scale, tsVOffset +
+                                   (vOffs + yOffs + 4.0) * scale),
+                         hAlign=hAlign, vAlign='center', scale=0.8 * scale *
+                         extraScale, maxWidth=maxWidth, transition='inLeft',
+                         transitionDelay=tDelay).autoRetain()
+
+        _txt(
+            180, 43, bs.Lstr(
+                resource='gameLeadersText',
+                subs=[('${COUNT}', str(self.getSession().getGameNumber()))]),
+            hAlign='center', extraScale=1.4, maxWidth=None)
+        _txt(-15, 4, bs.Lstr(resource='playerText'), hAlign='left')
+        _txt(180, 4, bs.Lstr(resource='killsText'))
+        _txt(280, 4, bs.Lstr(resource='deathsText'), maxWidth=100)
+
+        scoreName = 'Score' if results is None else results._getScoreName()
+        translated = bs.Lstr(translate=('scoreNames', scoreName))
+
+        _txt(390, 0, translated)
+
+        topKillCount = 0
+        topKilledCount = 99999
+        topScore = 0 if(
+            len(playersSorted) == 0) else _getPlayerScore(
+            playersSorted[0])
+
+        for p in playersSorted:
+            topKillCount = max(topKillCount, p.accumKillCount)
+            topKilledCount = min(topKilledCount, p.accumKilledCount)
+
+        def _scoreTxt(text, xOffs, highlight, delay, maxWidth=70.0):
+            bsUtils.Text(
+                text,
+                position=(tsHOffs + xOffs * scale, tsVOffset + (vOffs + 15)
+                          * scale),
+                scale=scale, color=(1.0, 0.9, 0.5, 1.0)
+                if highlight else(0.5, 0.5, 0.6, 0.5), hAlign='right',
+                vAlign='center', maxWidth=maxWidth, transition='inLeft',
+                transitionDelay=tDelay + delay).autoRetain()
+
+        for p in playersSorted:
+            tDelay += 50
+            vOffs -= spacing
+            bsUtils.Image(
+                p.getIcon(),
+                position=(tsHOffs - 12 * scale,
+                          tsVOffset + (vOffs + 15.0) * scale),
+                scale=(30.0 * scale, 30.0 * scale),
+                transition='inLeft', transitionDelay=tDelay).autoRetain()
+            bsUtils.Text(
+                bs.Lstr(value=p.getName(full=True)),
+                maxWidth=160, scale=0.75 * scale,
+                position=(tsHOffs + 10.0 * scale,
+                          tsVOffset + (vOffs + 15) * scale),
+                hAlign='left', vAlign='center', color=bs.getSafeColor(
+                    p.getTeam().color + (1,)),
+                transition='inLeft', transitionDelay=tDelay).autoRetain()
+            _scoreTxt(
+                str(p.accumKillCount),
+                180, p.accumKillCount == topKillCount, 100)
+            _scoreTxt(str(p.accumKilledCount), 280,
+                      p.accumKilledCount == topKilledCount, 100)
+            _scoreTxt(
+                _getPlayerScoreStr(p),
+                390, _getPlayerScore(p) == topScore, 200)
+
+
+class FreeForAllVictoryScoreScreenActivity(TeamsScoreScreenActivity):
+
+    def __init__(self, settings={}):
+        TeamsScoreScreenActivity.__init__(self, settings=settings)
+        self._transitionTime = 500  # keeps prev activity alive while we fade in
+
+        self._cymbalSound = bs.getSound('cymbal')
+
+    def onBegin(self):
+        bsInternal._setAnalyticsScreen('FreeForAll Score Screen')
+        TeamsScoreScreenActivity.onBegin(self)
+
+        yBase = 100
+        tsHOffs = -305
+        tDelay = 1000
+        scale = 1.2
+        spacing = 37.0
+
+        # we include name and previous score in the sort to reduce the amount
+        # of random jumping around the list we do in cases of ties
+        playerOrderPrev = list(self.players)
+        playerOrderPrev.sort(reverse=True, key=lambda player: (
+            player.getTeam().sessionData['previousScore'],
+            player.getName(full=True)))
+        playerOrder = list(self.players)
+        playerOrder.sort(reverse=True,
+                         key=lambda player: (
+                             player.getTeam().sessionData['score'],
+                             player.getTeam().sessionData['score'],
+                             player.getName(full=True)))
+
+        vOffs = -74.0 + spacing*len(playerOrderPrev)*0.5
+
+        delay1 = 1300+100
+        delay2 = 2900+100
+        delay3 = 2900+100
+
+        orderChange = playerOrder != playerOrderPrev
+
+        if orderChange:
+            delay3 += 1500
+
+        bs.gameTimer(300, bs.Call(bs.playSound, self._scoreDisplaySound))
+        self.showPlayerScores(
+            delay=1, results=self.settings['results'],
+            scale=1.2, xOffset=-110)
+
+        soundTimes = set()
+
+        def _scoreTxt(
+                text, xOffs, yOffs, highlight, delay, extraScale, flash=False):
+            return bsUtils.Text(text,
+                                position=(tsHOffs+xOffs*scale,
+                                          yBase+(yOffs+vOffs+2.0)*scale),
+                                scale=scale*extraScale,
+                                color=((1.0, 0.7, 0.3, 1.0) if highlight
+                                       else (0.7, 0.7, 0.7, 0.7)),
+                                hAlign='right',
+                                transition='inLeft',
+                                transitionDelay=tDelay+delay,
+                                flash=flash).autoRetain()
+        vOffs -= spacing
+
+        slideAmt = 0.0
+
+        transitionTime = 250
+        transitionTime2 = 250
+
+        title = bsUtils.Text(
+            bs.Lstr(
+                resource='firstToSeriesText',
+                subs=[('${COUNT}', str(self.getSession()._ffaSeriesLength))]),
+            scale=1.05 * scale,
+            position=(tsHOffs - 0.0 * scale, yBase + (vOffs + 50.0) * scale),
+            hAlign='center', color=(0.5, 0.5, 0.5, 0.5),
+            transition='inLeft', transitionDelay=tDelay).autoRetain()
+
+        vOffs -= 25
+        vOffsStart = vOffs
+
+        bs.gameTimer(tDelay+delay3, bs.WeakCall(
+            self._safeAnimate,
+            title.positionCombine,
+            'input0', {0: tsHOffs-0.0*scale,
+                       transitionTime2: tsHOffs-(0.0+slideAmt)*scale}))
+
+        for i, player in enumerate(playerOrderPrev):
+            vOffs2 = vOffsStart - spacing * (playerOrder.index(player))
+
+            bs.gameTimer(tDelay+300, bs.Call(bs.playSound,
+                                             self._scoreDisplaySoundSmall))
+            if orderChange:
+                bs.gameTimer(tDelay+delay2+100,
+                             bs.Call(bs.playSound, self._cymbalSound))
+
+            img = bsUtils.Image(
+                player.getIcon(),
+                position=(tsHOffs - 72.0 * scale,
+                          yBase + (vOffs + 15.0) * scale),
+                scale=(30.0 * scale, 30.0 * scale),
+                transition='inLeft', transitionDelay=tDelay).autoRetain()
+            bs.gameTimer(
+                tDelay + delay2, bs.WeakCall(
+                    self._safeAnimate, img.positionCombine, 'input1',
+                    {0: yBase + (vOffs + 15.0) * scale, transitionTime: yBase +
+                     (vOffs2 + 15.0) * scale}))
+            bs.gameTimer(
+                tDelay + delay3, bs.WeakCall(
+                    self._safeAnimate, img.positionCombine, 'input0',
+                    {0: tsHOffs - 72.0 * scale, transitionTime2: tsHOffs -
+                     (72.0 + slideAmt) * scale}))
+            txt = bsUtils.Text(
+                bs.Lstr(value=player.getName(full=True)),
+                maxWidth=130.0, scale=0.75 * scale,
+                position=(tsHOffs - 50.0 * scale,
+                          yBase + (vOffs + 15.0) * scale),
+                hAlign='left', vAlign='center', color=bs.getSafeColor(
+                    player.getTeam().color + (1,)),
+                transition='inLeft', transitionDelay=tDelay).autoRetain()
+            bs.gameTimer(
+                tDelay + delay2, bs.WeakCall(
+                    self._safeAnimate, txt.positionCombine, 'input1',
+                    {0: yBase + (vOffs + 15.0) * scale, transitionTime: yBase +
+                     (vOffs2 + 15.0) * scale}))
+            bs.gameTimer(
+                tDelay + delay3, bs.WeakCall(
+                    self._safeAnimate, txt.positionCombine, 'input0',
+                    {0: tsHOffs - 50.0 * scale, transitionTime2: tsHOffs -
+                     (50.0 + slideAmt) * scale}))
+
+            txtNum = bsUtils.Text(
+                '#' + str(i + 1),
+                scale=0.55 * scale,
+                position=(tsHOffs - 95.0 * scale,
+                          yBase + (vOffs + 8.0) * scale),
+                hAlign='right', color=(0.6, 0.6, 0.6, 0.6),
+                transition='inLeft', transitionDelay=tDelay).autoRetain()
+            bs.gameTimer(
+                tDelay + delay3, bs.WeakCall(
+                    self._safeAnimate, txtNum.positionCombine, 'input0',
+                    {0: tsHOffs - 95.0 * scale, transitionTime2: tsHOffs -
+                     (95.0 + slideAmt) * scale}))
+
+            sTxt = _scoreTxt(
+                str(player.getTeam().sessionData['previousScore']),
+                80, 0, False, 0, 1.0)
+            bs.gameTimer(
+                tDelay + delay2, bs.WeakCall(
+                    self._safeAnimate, sTxt.positionCombine, 'input1',
+                    {0: yBase + (vOffs + 2.0) * scale, transitionTime: yBase +
+                     (vOffs2 + 2.0) * scale}))
+            bs.gameTimer(tDelay+delay3, bs.WeakCall(
+                self._safeAnimate, sTxt.positionCombine,
+                'input0', {0: tsHOffs+80*scale,
+                           transitionTime2: tsHOffs+(80-slideAmt)*scale}))
+
+            scoreChange = player.getTeam().sessionData['score'] \
+                - player.getTeam().sessionData['previousScore']
+            if scoreChange > 0:
+                x = 113
+                y = 3.0
+                sTxt2 = _scoreTxt(
+                    '+' + str(scoreChange),
+                    x, y, True, 0, 0.7, flash=True)
+                bs.gameTimer(tDelay + delay2, bs.WeakCall(
+                    self._safeAnimate, sTxt2.positionCombine,
+                    'input1',
+                    {0: yBase + (vOffs + y + 2.0) * scale,
+                     transitionTime: yBase + (vOffs2 + y + 2.0) *
+                     scale}))
+                bs.gameTimer(
+                    tDelay + delay3, bs.WeakCall(
+                        self._safeAnimate, sTxt2.positionCombine, 'input0',
+                        {0: tsHOffs + x * scale, transitionTime2: tsHOffs +
+                         (x - slideAmt) * scale}))
+
+                def _safeSetAttr(node, attr, value):
+                    if node.exists():
+                        setattr(node, attr, value)
+
+                bs.gameTimer(tDelay+delay1, bs.Call(
+                    _safeSetAttr, sTxt.node, 'color', (1, 1, 1, 1)))
+                for j in range(scoreChange):
+                    bs.gameTimer(tDelay+delay1+150*j, bs.Call(
+                        _safeSetAttr, sTxt.node, 'text',
+                        str(player.getTeam().sessionData['previousScore']+j+1)))
+                    t = tDelay+delay1+150*j
+                    if not t in soundTimes:
+                        soundTimes.add(t)
+                        bs.gameTimer(
+                            t, bs.Call(
+                                bs.playSound, self._scoreDisplaySoundSmall))
+
+            vOffs -= spacing
+
+    def _safeAnimate(self, node, attr, keys):
+        if node.exists():
+            bsUtils.animate(node, attr, keys)
+
+
+class DrawScoreScreenActivity(TeamsScoreScreenActivity):
+
+    def __init__(self, settings={}):
+        TeamsScoreScreenActivity.__init__(self, settings=settings)
+
+    def onTransitionIn(self):
+        TeamsScoreScreenActivity.onTransitionIn(self, music=None)
+
+    def onBegin(self):
+        bsInternal._setAnalyticsScreen('Draw Score Screen')
+        TeamsScoreScreenActivity.onBegin(self)
+        bsUtils.ZoomText(bs.Lstr(resource='drawText'), position=(0, 0),
+                         maxWidth=400,
+                         shiftPosition=(-220, 0), shiftDelay=2000,
+                         flash=False, trail=False, jitter=1.0).autoRetain()
+        bs.gameTimer(350, bs.Call(bs.playSound, self._scoreDisplaySound))
+
+        if 'results' in self.settings:
+            r = self.settings['results']
+        else:
+            r = None
+
+        self.showPlayerScores(results=r)
+
+
+class TeamVictoryScoreScreenActivity(TeamsScoreScreenActivity):
+
+    def __init__(self, settings={}):
+        TeamsScoreScreenActivity.__init__(self, settings=settings)
+
+    def onBegin(self):
+        bsInternal._setAnalyticsScreen('Teams Score Screen')
+        TeamsScoreScreenActivity.onBegin(self)
+
+        # call bsOnGameEnd() for any modules that define it..
+        # this is intended as a simple way to upload game scores to a server or
+        # whatnot TODO...
+
+        height = 130
+        activeTeamCount = len(self.teams)
+        v = (height*activeTeamCount)/2 - height/2
+        i = 0
+        shiftTime = 2500
+
+        # usually we say 'Best of 7', but if the language prefers we can say
+        # 'First to 4'
+        if bsUtils._getResource('bestOfUseFirstToInstead'):
+            bestTxt = bs.Lstr(
+                resource='firstToSeriesText', subs=[
+                    ('${COUNT}', str(self.getSession()._seriesLength/2+1))])
+        else:
+            bestTxt = bs.Lstr(
+                resource='bestOfSeriesText', subs=[
+                    ('${COUNT}', str(self.getSession()._seriesLength))])
+
+        bsUtils.ZoomText(bestTxt,
+                         position=(0, 175),
+                         shiftPosition=(-250, 175), shiftDelay=2500,
+                         flash=False, trail=False, hAlign='center',
+                         scale=0.25,
+                         color=(0.5, 0.5, 0.5, 1.0), jitter=3.0).autoRetain()
+        for team in self.teams:
+            bs.gameTimer(i*150+150, bs.WeakCall(
+                self._showTeamName,
+                v-i*height, team, i*200, shiftTime-(i*150+150)))
+            bs.gameTimer(i*150+500, bs.Call(bs.playSound,
+                                            self._scoreDisplaySoundSmall))
+            scored = (team is self.settings['winner'])
+            delay = 200
+            if scored:
+                delay = 1200
+                bs.gameTimer(i*150+200, bs.WeakCall(
+                    self._showTeamOldScore,
+                    v-i*height, team, shiftTime-(i*150+200)))
+                bs.gameTimer(i*150+1500, bs.Call(bs.playSound,
+                                                 self._scoreDisplaySound))
+
+            bs.gameTimer(i*150+delay, bs.WeakCall(
+                self._showTeamScore,
+                v-i*height, team, scored, i*200+100, shiftTime-(i*150+delay)))
+            i += 1
+        self.showPlayerScores()
+
+    def _showTeamName(self, posV, team, killDelay, shiftDelay):
+        bsUtils.ZoomText(bs.Lstr(value='${A}:', subs=[('${A}', team.name)]),
+                         # team.name+":",
+                         position=(100, posV),
+                         shiftPosition=(-150, posV), shiftDelay=shiftDelay,
+                         flash=False, trail=False, hAlign='right', maxWidth=300,
+                         color=team.color, jitter=1.0).autoRetain()
+
+    def _showTeamOldScore(self, posV, team, shiftDelay):
+        bsUtils.ZoomText(
+            str(team.sessionData['score'] - 1),
+            position=(150, posV),
+            maxWidth=100, color=(0.6, 0.6, 0.7),
+            shiftPosition=(-100, posV),
+            shiftDelay=shiftDelay, flash=False, trail=False, lifespan=1000,
+            hAlign='left', jitter=1.0).autoRetain()
+
+    def _showTeamScore(self, posV, team, scored, killDelay, shiftDelay):
+        bsUtils.ZoomText(str(team.sessionData['score']), position=(150, posV),
+                         maxWidth=100,
+                         color=(1.0, 0.9, 0.5) if scored else (0.6, 0.6, 0.7),
+                         shiftPosition=(-100, posV), shiftDelay=shiftDelay,
+                         flash=scored, trail=scored,
+                         hAlign='left', jitter=1.0,
+                         trailColor=(1, 0.8, 0.0, 0)).autoRetain()
+
+
+class TeamSeriesVictoryScoreScreenActivity(TeamsScoreScreenActivity):
+
+    def __init__(self, settings={}):
+        TeamsScoreScreenActivity.__init__(self, settings=settings)
+        self._minViewTime = 15000
+        self._isFFA = isinstance(self.getSession(), bs.FreeForAllSession)
+        self._allowServerRestart = True
+
+    def onTransitionIn(self):
+        # we dont yet want music and stuff..
+        TeamsScoreScreenActivity.onTransitionIn(
+            self, music=None, showTips=False)
+
+    def onBegin(self):
+        bsInternal._setAnalyticsScreen(
+            'FreeForAll Series Victory Screen'
+            if self._isFFA else 'Teams Series Victory Screen')
+
+        if bs.getEnvironment()['interfaceType'] == 'large':
+            s = bs.Lstr(resource='pressAnyKeyButtonPlayAgainText')
+        else:
+            s = bs.Lstr(resource='pressAnyButtonPlayAgainText')
+
+        TeamsScoreScreenActivity.onBegin(
+            self, showUpNext=False, customContinueMessage=s)
+
+        winningTeam = self.settings['winner']
+
+        # pause a moment before playing victory music
+        bs.gameTimer(600, bs.WeakCall(self._playVictoryMusic))
+        bs.gameTimer(4400, bs.WeakCall(
+            self._showWinner, self.settings['winner']))
+        bs.gameTimer(4400+200, bs.Call(bs.playSound, self._scoreDisplaySound))
+
+        # make sure to exclude players without teams (this means they're
+        # still doing selection)
+        if self._isFFA:
+            playersSorted = [
+                [p.getTeam().sessionData['score'],
+                 p.getName(full=True),
+                 p] for name, p in self.scoreSet.getValidPlayers().items()
+                if p.getTeam() is not None]
+            playersSorted.sort(reverse=True)
+        else:
+            playersSorted = [
+                [p.score, p.nameFull, p]
+                for name, p in self.scoreSet.getValidPlayers().items()]
+            playersSorted.sort(reverse=True)
+
+        tsHeight = 300
+        tsHOffs = -390
+        t = 6400
+        tIncr = 120
+
+        if self._isFFA:
+            txt = bs.Lstr(value='${A}:',
+                          subs=[('${A}', bs.Lstr(
+                              resource='firstToFinalText', subs=[
+                                  ('${COUNT}',
+                                   str(self.getSession()._ffaSeriesLength))]))])
+        else:
+            # some languages may prefer to always show 'first to X' instead of
+            # 'best of X' FIXME - this will affect all clients connected to
+            # us even if they're not using a language with this enabled.. not
+            # the end of the world but something to be aware of.
+            if bsUtils._getResource('bestOfUseFirstToInstead'):
+                txt = bs.Lstr(value='${A}:', subs=[
+                    ('${A}', bs.Lstr(resource='firstToFinalText', subs=[
+                        ('${COUNT}',
+                         str(self.getSession()._seriesLength/2+1))]))])
+            else:
+                txt = bs.Lstr(value='${A}:', subs=[
+                    ('${A}', bs.Lstr(resource='bestOfFinalText', subs=[
+                        ('${COUNT}', str(self.getSession()._seriesLength))]))])
+
+        bsUtils.Text(txt,
+                     vAlign='center',
+                     maxWidth=300,
+                     color=(0.5, 0.5, 0.5, 1.0), position=(0, 220),
+                     scale=1.2, transition='inTopSlow', hAlign='center',
+                     transitionDelay=tIncr*4).autoRetain()
+
+        winScore = (self.getSession()._seriesLength-1)/2+1
+        loseScore = 0
+        for team in self.teams:
+            if team.sessionData['score'] != winScore:
+                loseScore = team.sessionData['score']
+
+        if not self._isFFA:
+            bsUtils.Text(
+                bs.Lstr(
+                    resource='gamesToText',
+                    subs=[('${WINCOUNT}', str(winScore)),
+                          ('${LOSECOUNT}', str(loseScore))]),
+                color=(0.5, 0.5, 0.5, 1.0),
+                maxWidth=160, vAlign='center', position=(0, -215),
+                scale=1.8, transition='inLeft', hAlign='center',
+                transitionDelay=4800 + tIncr * 4).autoRetain()
+
+        if self._isFFA:
+            vExtra = 120
+        else:
+            vExtra = 0
+
+        # show game MVP
+        if not self._isFFA:
+            mvp, mvpName = None, None
+            for p in playersSorted:
+                if p[2].getTeam() == winningTeam:
+                    mvp = p[2]
+                    mvpName = p[1]
+                    break
+            if mvp is not None:
+                bsUtils.Text(bs.Lstr(resource='mostValuablePlayerText'),
+                             color=(0.5, 0.5, 0.5, 1.0),
+                             vAlign='center',
+                             maxWidth=300,
+                             position=(180, tsHeight/2+15), transition='inLeft',
+                             hAlign='left', transitionDelay=t).autoRetain()
+                t += 4*tIncr
+
+                bsUtils.Image(
+                    mvp.getIcon(),
+                    position=(230, tsHeight / 2 - 55 + 14 - 5),
+                    scale=(70, 70),
+                    transition='inLeft', transitionDelay=t).autoRetain()
+                bsUtils.Text(
+                    bs.Lstr(value=mvpName),
+                    position=(280, tsHeight / 2 - 55 + 15 - 5),
+                    hAlign='left', vAlign='center', maxWidth=170, scale=1.3,
+                    color=bs.getSafeColor(mvp.getTeam().color + (1,)),
+                    transition='inLeft', transitionDelay=t).autoRetain()
+                t += 4*tIncr
+
+        # most violent
+        mostKills = 0
+        for p in playersSorted:
+            if p[2].killCount >= mostKills:
+                mvp = p[2]
+                mvpName = p[1]
+                mostKills = p[2].killCount
+        if mvp is not None:
+            bsUtils.Text(bs.Lstr(resource='mostViolentPlayerText'),
+                         color=(0.5, 0.5, 0.5, 1.0),
+                         vAlign='center',
+                         maxWidth=300,
+                         position=(180, tsHeight/2-150+vExtra+15),
+                         transition='inLeft',
+                         hAlign='left', transitionDelay=t).autoRetain()
+            bsUtils.Text(
+                bs.Lstr(
+                    value='(${A})',
+                    subs=[('${A}', bs.Lstr(
+                        resource='killsTallyText',
+                        subs=[('${COUNT}', str(mostKills))]))]),
+                position=(260, tsHeight / 2 - 150 - 15 + vExtra),
+                color=(0.3, 0.3, 0.3, 1.0),
+                scale=0.6, hAlign='left', transition='inLeft',
+                transitionDelay=t).autoRetain()
+            t += 4*tIncr
+
+            bsUtils.Image(
+                mvp.getIcon(),
+                position=(233, tsHeight / 2 - 150 - 30 - 46 + 25 + vExtra),
+                scale=(50, 50),
+                transition='inLeft', transitionDelay=t).autoRetain()
+            bsUtils.Text(
+                bs.Lstr(value=mvpName),
+                position=(270, tsHeight / 2 - 150 - 30 - 36 + vExtra + 15),
+                hAlign='left', vAlign='center', maxWidth=180,
+                color=bs.getSafeColor(mvp.getTeam().color + (1,)),
+                transition='inLeft', transitionDelay=t).autoRetain()
+            t += 4*tIncr
+
+        # most killed
+        mostKilled = 0
+        mkp, mkpName = None, None
+        for p in playersSorted:
+            if p[2].killedCount >= mostKilled:
+                mkp = p[2]
+                mkpName = p[1]
+                mostKilled = p[2].killedCount
+        if mkp is not None:
+            bsUtils.Text(bs.Lstr(resource='mostViolatedPlayerText'),
+                         color=(0.5, 0.5, 0.5, 1.0),
+                         vAlign='center',
+                         maxWidth=300,
+                         position=(180, tsHeight/2-300+vExtra+15),
+                         transition='inLeft',
+                         hAlign='left', transitionDelay=t).autoRetain()
+            bsUtils.Text(
+                bs.Lstr(
+                    value='(${A})',
+                    subs=[('${A}', bs.Lstr(
+                        resource='deathsTallyText',
+                        subs=[('${COUNT}', str(mostKilled))]))]),
+                position=(260, tsHeight / 2 - 300 - 15 + vExtra),
+                hAlign='left', scale=0.6, color=(0.3, 0.3, 0.3, 1.0),
+                transition='inLeft', transitionDelay=t).autoRetain()
+            t += 4*tIncr
+            bsUtils.Image(
+                mkp.getIcon(),
+                position=(233, tsHeight / 2 - 300 - 30 - 46 + 25 + vExtra),
+                scale=(50, 50),
+                transition='inLeft', transitionDelay=t).autoRetain()
+            bsUtils.Text(bs.Lstr(value=mkpName),
+                         position=(270, tsHeight/2-300-30-36+vExtra+15),
+                         hAlign='left', vAlign='center',
+                         color=bs.getSafeColor(mkp.getTeam().color+(1,)),
+                         maxWidth=180, transition='inLeft',
+                         transitionDelay=t).autoRetain()
+            t += 4*tIncr
+
+        # now show individual scores
+        tDelay = t
+        bsUtils.Text(bs.Lstr(resource='finalScoresText'),
+                     color=(0.5, 0.5, 0.5, 1.0),
+                     position=(tsHOffs, tsHeight/2),
+                     transition='inRight',
+                     transitionDelay=tDelay).autoRetain()
+        tDelay += 4*tIncr
+
+        vOffs = 0.0
+        tDelay += len(playersSorted) * 8*tIncr
+        for score, name, p in playersSorted:
+            tDelay -= 4*tIncr
+            vOffs -= 40
+            bsUtils.Text(str(p.getTeam().sessionData['score'])
+                         if self._isFFA else str(p.score),
+                         color=(0.5, 0.5, 0.5, 1.0),
+                         position=(tsHOffs + 230, tsHeight / 2 + vOffs),
+                         hAlign='right', transition='inRight',
+                         transitionDelay=tDelay).autoRetain()
+            tDelay -= 4*tIncr
+
+            bsUtils.Image(
+                p.getIcon(),
+                position=(tsHOffs - 72, tsHeight / 2 + vOffs + 15),
+                scale=(30, 30),
+                transition='inLeft', transitionDelay=tDelay).autoRetain()
+            bsUtils.Text(
+                bs.Lstr(value=name),
+                position=(tsHOffs - 50, tsHeight / 2 + vOffs + 15),
+                hAlign='left', vAlign='center', maxWidth=180,
+                color=bs.getSafeColor(p.getTeam().color + (1,)),
+                transition='inRight', transitionDelay=tDelay).autoRetain()
+
+        bs.gameTimer(15000, bs.WeakCall(self._showTips))
+
+    def _showTips(self):
+        self._tipsText = bsUtils.TipsText(offsY=70)
+
+    def _playVictoryMusic(self):
+        # make sure we dont stomp on the next activity's music choice
+        if not self.isTransitioningOut():
+            bs.playMusic('Victory')
+
+    def _showWinner(self, team):
+        if not self._isFFA:
+            offsV = 0
+            bsUtils.ZoomText(team.name, position=(0, 97),
+                             color=team.color, scale=1.15, jitter=1.0,
+                             maxWidth=250).autoRetain()
+        else:
+            offsV = -80
+            if len(team.players) == 1:
+                i = bsUtils.Image(
+                    team.players[0].getIcon(),
+                    position=(0, 143),
+                    scale=(100, 100)).autoRetain()
+                bsUtils.animate(i.node, 'opacity', {0: 0.0, 250: 1.0})
+                bsUtils.ZoomText(bs.Lstr(
+                    value=team.players[0].getName(
+                        full=True, icon=False)),
+                    position=(0, 97 + offsV),
+                    color=team.color, scale=1.15, jitter=1.0,
+                    maxWidth=250).autoRetain()
+
+        sExtra = 1.0 if self._isFFA else 1.0
+
+        # some languages say "FOO WINS" differently for teams vs players
+        if isinstance(self.getSession(), bs.FreeForAllSession):
+            winsResource = 'seriesWinLine1PlayerText'
+        else:
+            winsResource = 'seriesWinLine1TeamText'
+        winsText = bs.Lstr(resource=winsResource)
+        # temp - if these come up as the english default, fall-back to the
+        # unified old form which is more likely to be translated
+
+        bsUtils.ZoomText(winsText, position=(0, -10 + offsV),
+                         color=team.color, scale=0.65 * sExtra, jitter=1.0,
+                         maxWidth=250).autoRetain()
+        bsUtils.ZoomText(bs.Lstr(resource='seriesWinLine2Text'),
+                         position=(0, -110 + offsV),
+                         scale=1.0 * sExtra, color=team.color, jitter=1.0,
+                         maxWidth=250).autoRetain()
+
+
+class TeamJoiningActivity(bsGame.JoiningActivity):
+    def __init__(self, settings={}):
+        bsGame.JoiningActivity.__init__(self, settings)
+
+    def onTransitionIn(self):
+        bsGame.JoiningActivity.onTransitionIn(self)
+
+        bsUtils.ControlsHelpOverlay(delay=1000).autoRetain()
+
+        # show info about the next up game
+        self._nextUpText = bsUtils.Text(
+            bs.Lstr(
+                value='${1} ${2}',
+                subs=[('${1}', bs.Lstr(resource='upFirstText')),
+                      ('${2}', self.getSession().getNextGameDescription())]),
+            hAttach='center', scale=0.7, vAttach='top', hAlign='center',
+            position=(0, -70),
+            flash=False, color=(0.5, 0.5, 0.5, 1.0),
+            transition='fadeIn', transitionDelay=5000)
+
+        # in teams mode, show our two team names
+        # (technically should have Lobby handle this, but this works for now)
+        if isinstance(bs.getSession(), bs.TeamsSession):
+            teamNames = [team.name for team in bs.getSession().teams]
+            teamColors = [tuple(team.color) + (0.5,)
+                          for team in bs.getSession().teams]
+            if len(teamNames) == 2:
+                for i in range(2):
+                    teamColor = (1, 0, 0)+(1,)
+                    bsUtils.Text(teamNames[i],
+                                 scale=0.7,
+                                 hAttach='center',
+                                 vAttach='top',
+                                 hAlign='center',
+                                 position=(-200+350*i, -100),
+                                 color=teamColors[i],
+                                 transition='fadeIn').autoRetain()
+
+        bsUtils.Text(bs.Lstr(
+            resource='mustInviteFriendsText',
+            subs=[('${GATHER}', bs.Lstr(
+                resource='gatherWindow.titleText'))]),
+            hAttach='center', scale=0.8, hostOnly=True,
+            vAttach='center', hAlign='center', position=(0, 0),
+            flash=False, color=(0, 1, 0, 1.0),
+            transition='fadeIn', transitionDelay=2000,
+            transitionOutDelay=7000).autoRetain()
+
+
+class TeamGameActivity(bs.GameActivity):
+    """
+    category: Game Flow Classes
+
+    Base class for teams and free-for-all mode games.
+    (Free-for-all is essentially just a special case where every
+    bs.Player has their own bs.Team)
+    """
+
+    @classmethod
+    def supportsSessionType(cls, sessionType):
+        """
+        Class method override;
+        returns True for bs.TeamsSessions and bs.FreeForAllSessions;
+        False otherwise.
+        """
+        return True if(
+            issubclass(sessionType, bs.TeamsSession)
+            or issubclass(sessionType, bs.FreeForAllSession)) else False
+
+    def __init__(self, settings={}):
+        bs.GameActivity.__init__(self, settings)
+        # by default we don't show kill-points in free-for-all
+        # (there's usually some activity-specific score and we dont
+        # wanna confuse things)
+        if isinstance(bs.getSession(), bs.FreeForAllSession):
+            self._showKillPoints = False
+
+    def onTransitionIn(self, music=None):
+        bs.GameActivity.onTransitionIn(self, music)
+
+        # on the first game, show the controls UI momentarily
+        # (unless we're being run in co-op mode, in which case we leave
+        # it up to them)
+        if not isinstance(self.getSession(), bs.CoopSession):
+            if not self.getSession()._haveShownControlsHelpOverlay:
+                delay = 4000
+                lifespan = 10000
+                if self._isSlowMotion:
+                    lifespan = int(lifespan*0.3)
+                bsUtils.ControlsHelpOverlay(
+                    delay=delay, lifespan=lifespan, scale=0.8,
+                    position=(380, 200),
+                    bright=True).autoRetain()
+                self.getSession()._haveShownControlsHelpOverlay = True
+
+    def onBegin(self):
+        bs.GameActivity.onBegin(self)
+
+        # a few achievements...
+        try:
+            # award a few achievements..
+            if issubclass(type(self.getSession()), bs.FreeForAllSession):
+                if len(self.players) >= 2:
+                    import bsAchievement
+                    bsAchievement._awardLocalAchievement('Free Loader')
+            elif issubclass(type(self.getSession()), bs.TeamsSession):
+                if len(self.players) >= 4:
+                    import bsAchievement
+                    bsAchievement._awardLocalAchievement('Team Player')
+        except Exception:
+            bs.printException()
+
+    def spawnPlayerSpaz(self, player, position=None, angle=None):
+        """
+        Method override; spawns and wires up a standard bs.PlayerSpaz for
+        a bs.Player.
+
+        If position or angle is not supplied, a default will be chosen based
+        on the bs.Player and their bs.Team.
+        """
+        if position is None:
+            # in teams-mode get our team-start-location
+            if isinstance(self.getSession(), bs.TeamsSession):
+                position = \
+                    self.getMap().getStartPosition(player.getTeam().getID())
+            else:
+                # otherwise do free-for-all spawn locations
+                position = self.getMap().getFFAStartPosition(self.players)
+
+        return bs.GameActivity.spawnPlayerSpaz(self, player, position, angle)
+
+    def end(
+            self, results=None, announceWinningTeam=True, announceDelay=100,
+            force=False):
+        """
+        Method override; announces the single winning team
+        unless 'announceWinningTeam' is False.  (useful for games where
+        there is not a single most-important winner).
+        """
+        # announce win (but only for the first finish() call)
+        # (also don't announce in co-op sessions; we leave that up to them)
+        if not isinstance(self.getSession(), bs.CoopSession):
+            doAnnounce = not self.hasEnded()
+            bs.GameActivity.end(
+                self, results, delay=2000+announceDelay, force=force)
+            # need to do this *after* end end call so that results is valid
+            if doAnnounce:
+                self.getSession().announceGameResults(
+                    self, results, delay=announceDelay,
+                    announceWinningTeam=announceWinningTeam)
+
+        # for co-op we just pass this up the chain with a delay added
+        # (in most cases)
+        # (team games expect a delay for the announce portion in teams/ffa
+        # mode so this keeps it consistent)
+        else:
+            # dont want delay on restarts..
+            if (type(results) is dict
+                    and 'outcome' in results
+                    and results['outcome'] == 'restart'):
+                delay = 0
+            else:
+                delay = 2000
+                bs.gameTimer(100, bs.Call(
+                    bs.playSound, bs.getSound("boxingBell")))
+            bs.GameActivity.end(self, results, delay=delay, force=force)
+
+
+class TeamBaseSession(bs.Session):
+    """
+    category: Game Flow Classes
+
+    Common base class for bs.TeamsSession and bs.FreeForAllSession.
+    Free-for-all-mode is essentially just teams-mode with each bs.Player having
+    their own bs.Team, so there is much overlap in functionality.
+    """
+
+    def __init__(self):
+        """
+        Sets up playlists and launches a bs.Activity to accept joiners.
+        """
+
+        mp = self.getMaxPlayers()
+        bsConfig = bs.getConfig()
+
+        if self._useTeams:
+            teamNames = bsConfig.get('Custom Team Names', gDefaultTeamNames)
+            teamColors = bsConfig.get('Custom Team Colors', gDefaultTeamColors)
+        else:
+            teamNames = None
+            teamColors = None
+
+        bs.Session.__init__(self,
+                            teamNames=teamNames, teamColors=teamColors,
+                            useTeamColors=self._useTeams,
+                            minPlayers=1, maxPlayers=mp)
+
+        self._seriesLength = gTeamSeriesLength
+        self._ffaSeriesLength = gFFASeriesLength
+
+        showTutorial = bsConfig.get('Show Tutorial', True)
+
+        if showTutorial:
+            # get this loading..
+            self._tutorialActivityInstance = bs.newActivity(
+                bsTutorial.TutorialActivity)
+        else:
+            self._tutorialActivityInstance = None
+
+        # TeamGameActivity uses this to display a help overlay on
+        # the first activity only
+        self._haveShownControlsHelpOverlay = False
+
+        try:
+            self._playlistName = bs.getConfig()[self._playlistSelectionVar]
+        except Exception:
+            self._playlistName = '__default__'
+        try:
+            self._playlistRandomize = bs.getConfig()[self._playlistRandomizeVar]
+        except Exception:
+            self._playlistRandomize = False
+
+        # which game activity we're on
+        self._gameNumber = 0
+
+        try:
+            playlists = bs.getConfig()[self._playlistsVar]
+        except Exception:
+            playlists = {}
+
+        if (self._playlistName != '__default__'
+                and self._playlistName in playlists):
+            # (make sure to copy this, as we muck with it in place once we've
+            # got it and we dont want that to affect our config)
+            playlist = copy.deepcopy(playlists[self._playlistName])
+        else:
+            if self._useTeams:
+                playlist = bsUtils._getDefaultTeamsPlaylist()
+            else:
+                playlist = bsUtils._getDefaultFreeForAllPlaylist()
+
+        # resolve types and whatnot to get our final playlist
+        playlistResolved = bsUtils._filterPlaylist(
+            playlist, sessionType=type(self),
+            addResolvedType=True)
+
+        if not playlistResolved:
+            raise Exception("playlist contains no valid games")
+
+        self._playlist = ShuffleList(
+            playlistResolved, shuffle=self._playlistRandomize)
+
+        # get a game on deck ready to go
+        self._currentGameSpec = None
+        self._nextGameSpec = self._playlist.pullNext()
+        self._nextGame = self._nextGameSpec['resolvedType']
+
+        # go ahead and instantiate the next game we'll
+        # use so it has lots of time to load
+        self._instantiateNextGame()
+
+        # start in our custom join screen
+        self.setActivity(bs.newActivity(TeamJoiningActivity))
+
+    def getNextGameDescription(self):
+        'Returns a description of the next game on deck'
+        return self._nextGameSpec['resolvedType'].getConfigDisplayString(
+            self._nextGameSpec)
+
+    def getGameNumber(self):
+        'Returns which game in the series is currently being played.'
+        return self._gameNumber
+
+    def onTeamJoin(self, team):
+        team.sessionData['previousScore'] = team.sessionData['score'] = 0
+
+    def getMaxPlayers(self):
+        """
+        Return the max number of bs.Players allowed to join the game at once.
+        """
+        if self._useTeams:
+            try:
+                return bs.getConfig()['Team Game Max Players']
+            except Exception:
+                return 8
+        else:
+            try:
+                return bs.getConfig()['Free-for-All Max Players']
+            except Exception:
+                return 8
+
+    def _instantiateNextGame(self):
+        self._nextGameInstance = bs.newActivity(
+            self._nextGameSpec['resolvedType'],
+            self._nextGameSpec['settings'])
+
+    def onPlayerRequest(self, player):
+        return bs.Session.onPlayerRequest(self, player)
+
+    def onActivityEnd(self, activity, results):
+
+        # if we have a tutorial to show,
+        # thats the first thing we do no matter what
+        if self._tutorialActivityInstance is not None:
+            self.setActivity(self._tutorialActivityInstance)
+            self._tutorialActivityInstance = None
+
+        # if we're leaving the tutorial activity,
+        # pop a transition activity to transition
+        # us into a round gracefully (otherwise we'd
+        # snap from one terrain to another instantly)
+        elif isinstance(activity, bsTutorial.TutorialActivity):
+            self.setActivity(bs.newActivity(bsGame.TransitionActivity))
+
+        # if we're in a between-round activity or a restart-activity,
+        # hop into a round
+        elif (isinstance(activity, bsGame.JoiningActivity)
+              or isinstance(activity, bsGame.TransitionActivity)
+              or isinstance(activity, bsGame.ScoreScreenActivity)):
+
+            # if we're coming from a series-end activity, reset scores
+            if isinstance(activity, TeamSeriesVictoryScoreScreenActivity):
+                self.scoreSet.reset()
+                self._gameNumber = 0
+                for team in self.teams:
+                    team.sessionData['score'] = 0
+            # otherwise just set accum (per-game) scores
+            else:
+                self.scoreSet.resetAccum()
+
+            nextGame = self._nextGameInstance
+
+            self._currentGameSpec = self._nextGameSpec
+            self._nextGameSpec = self._playlist.pullNext()
+            self._gameNumber += 1
+
+            # instantiate the next now so they have plenty of time to load
+            self._instantiateNextGame()
+
+            # (re)register all players and wire the score-set to our next
+            # activity
+            for p in self.players:
+                # ..but only ones who have completed joining
+                if p.getTeam() is not None:
+                    self.scoreSet.registerPlayer(p)
+            self.scoreSet.setActivity(nextGame)
+
+            # now flip the current activity
+            self.setActivity(nextGame)
+
+        # if we're leaving a round, go to the score screen
+        else:
+            # teams mode
+            if self._useTeams:
+                winners = results._getWinners()
+                # if everyone has the same score, call it a draw
+                if len(winners) < 2:
+                    self.setActivity(bs.newActivity(DrawScoreScreenActivity))
+                else:
+                    winner = winners[0]['teams'][0]
+                    winner.sessionData['score'] += 1
+                    # if a team has won, show final victory screen...
+                    if winner.sessionData['score'] >= (
+                            self._seriesLength - 1) / 2 + 1:
+                        self.setActivity(
+                            bs.newActivity(
+                                TeamSeriesVictoryScoreScreenActivity,
+                                {'winner': winner}))
+                    else:
+                        self.setActivity(
+                            bs.newActivity(
+                                TeamVictoryScoreScreenActivity,
+                                {'winner': winner}))
+            # free-for-all mode
+            else:
+                winners = results._getWinners()
+
+                # if there's multiple players and everyone has the same score,
+                # call it a draw
+                if len(self.players) > 1 and len(winners) < 2:
+                    self.setActivity(bs.newActivity(
+                        DrawScoreScreenActivity,
+                        {'results': results}))
+                else:
+                    # award different point amounts based on number of players
+                    pointAwards = self._getFFAPointAwards()
+
+                    for i, winner in enumerate(winners):
+                        for team in winner['teams']:
+                            points = pointAwards[i] if i in pointAwards else 0
+                            team.sessionData['previousScore'] = \
+                                team.sessionData['score']
+                            team.sessionData['score'] += points
+
+                    seriesWinners = [team for team in self.teams
+                                     if team.sessionData['score']
+                                     >= self._ffaSeriesLength]
+                    seriesWinners.sort(
+                        reverse=True, key=lambda
+                        team: (team.sessionData['score']))
+                    if len(seriesWinners) == 1 or (
+                            len(seriesWinners) > 1
+                            and seriesWinners[0].sessionData['score'] !=
+                            seriesWinners[1].sessionData['score']):
+                        self.setActivity(
+                            bs.newActivity(
+                                TeamSeriesVictoryScoreScreenActivity,
+                                {'winner': seriesWinners[0]}))
+                    else:
+                        self.setActivity(
+                            bs.newActivity(
+                                FreeForAllVictoryScoreScreenActivity,
+                                {'results': results}))
+
+    def announceGameResults(self, activity, results, delay,
+                            announceWinningTeam=True):
+        """
+        Show game results at the end of a game
+        (before transitioning to a score screen).
+        This will include a zoom-text of 'BLUE WINS'
+        or whatnot, along with a possible audio
+        announcement of the same.
+        """
+
+        bs.gameTimer(delay, bs.Call(bs.playSound, bs.getSound("boxingBell")))
+        if announceWinningTeam:
+            winningTeam = results._getWinningTeam()
+            if winningTeam is not None:
+                # have all players celebrate
+                for player in winningTeam.players:
+                    try:
+                        player.actor.node.handleMessage('celebrate', 10000)
+                    except Exception:
+                        pass
+                activity.cameraFlash()
+
+                # some languages say "FOO WINS" different for teams vs players
+                if isinstance(self, bs.FreeForAllSession):
+                    winsResource = 'winsPlayerText'
+                else:
+                    winsResource = 'winsTeamText'
+                winsText = bs.Lstr(resource=winsResource, subs=[
+                                   ('${NAME}', winningTeam.name)])
+                activity.showZoomMessage(
+                    winsText, scale=0.85, color=bsUtils.getNormalizedColor(
+                        winningTeam.color))
+
+
+class FreeForAllSession(TeamBaseSession):
+    """
+    category: Game Flow Classes
+
+    bs.Session type for free-for-all mode games.
+    """
+    _useTeams = False
+    _playlistSelectionVar = 'Free-for-All Playlist Selection'
+    _playlistRandomizeVar = 'Free-for-All Playlist Randomize'
+    _playlistsVar = 'Free-for-All Playlists'
+
+    def _getFFAPointAwards(self):
+        """
+        Returns the number of points awarded for different
+        rankings based on the current number of players.
+        """
+        if len(self.players) == 1:
+            pointAwards = {}
+        elif len(self.players) == 2:
+            pointAwards = {0: 6}
+        elif len(self.players) == 3:
+            pointAwards = {0: 6, 1: 3}
+        elif len(self.players) == 4:
+            pointAwards = {0: 8, 1: 4, 2: 2}
+        elif len(self.players) == 5:
+            pointAwards = {0: 8, 1: 4, 2: 2}
+        elif len(self.players) == 6:
+            pointAwards = {0: 8, 1: 4, 2: 2}
+        else:
+            pointAwards = {0: 8, 1: 4, 2: 2, 3: 1}
+        return pointAwards
+
+    def __init__(self):
+        bsInternal._incrementAnalyticsCount('Free-for-all session start')
+        TeamBaseSession.__init__(self)
+
+
+class TeamsSession(TeamBaseSession):
+    """
+    category: Game Flow Classes
+
+    bs.Session type for teams mode games.
+    """
+    _useTeams = True
+    _playlistSelectionVar = 'Team Tournament Playlist Selection'
+    _playlistRandomizeVar = 'Team Tournament Playlist Randomize'
+    _playlistsVar = 'Team Tournament Playlists'
+
+    def __init__(self):
+        bsInternal._incrementAnalyticsCount('Teams session start')
+        TeamBaseSession.__init__(self)