From 255ebbc048effcb97d2d92cc1de81961acb5b06b Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Tue, 15 Mar 2016 15:51:59 -0700
Subject: [PATCH] Ads for free campaign players

Display leaderboard ads on campaign and play views.
Do no show ads in classroom, picoCTF, or to teachers.
Add no ads blurb to subscription features matrix.
Scale game UI for ads on short screens.

Closes #3491
---
 app/lib/surface/Surface.coffee               |  15 +-
 app/locale/en.coffee                         |   1 +
 app/models/User.coffee                       |  12 +-
 app/styles/modal/subscribe-modal.sass        |   2 +-
 app/styles/play/campaign-view.sass           |   8 +
 app/styles/play/level.sass                   |   9 +-
 app/templates/core/subscribe-modal.jade      |  12 +-
 app/templates/play/campaign-view.jade        | 337 ++++++++++---------
 app/templates/play/level.jade                |  72 ++--
 app/templates/play/modal/buy-gems-modal.jade |   2 +-
 app/views/play/CampaignView.coffee           |   6 +
 app/views/play/level/PlayLevelView.coffee    |  20 +-
 12 files changed, 292 insertions(+), 204 deletions(-)

diff --git a/app/lib/surface/Surface.coffee b/app/lib/surface/Surface.coffee
index 0c0d8dd64..51b2e2789 100644
--- a/app/lib/surface/Surface.coffee
+++ b/app/lib/surface/Surface.coffee
@@ -530,11 +530,20 @@ module.exports = Surface = class Surface extends CocoClass
       newWidth = 0.55 * pageWidth
       newHeight = newWidth / aspectRatio
     return unless newWidth > 0 and newHeight > 0
-    return if newWidth is oldWidth and newHeight is oldHeight and not @options.spectateGame
-    return if newWidth < 200 or newHeight < 200
+
     #scaleFactor = if application.isIPadApp then 2 else 1  # Retina
     scaleFactor = 1
-    @normalCanvas.add(@webGLCanvas).attr width: newWidth * scaleFactor, height: newHeight * scaleFactor
+    if @options.stayVisible
+      availableHeight = window.innerHeight
+      availableHeight -= $('.ad-container').outerHeight() 
+      availableHeight -= $('#game-area').outerHeight() - $('#canvas-wrapper').outerHeight()
+      scaleFactor = availableHeight / newHeight if availableHeight < newHeight
+    newWidth *= scaleFactor
+    newHeight *= scaleFactor
+
+    return if newWidth is oldWidth and newHeight is oldHeight and not @options.spectateGame
+    return if newWidth < 200 or newHeight < 200
+    @normalCanvas.add(@webGLCanvas).attr width: newWidth, height: newHeight
 
     # Cannot do this to the webGLStage because it does not use scaleX/Y.
     # Instead the LayerAdapter scales webGL-enabled layers.
diff --git a/app/locale/en.coffee b/app/locale/en.coffee
index e1b26dd8b..6f12fe859 100644
--- a/app/locale/en.coffee
+++ b/app/locale/en.coffee
@@ -502,6 +502,7 @@
     feature5: "Video tutorials"
     feature6: "Premium email support"
     feature7: "Private <strong>Clans</strong>"
+    feature8: "<strong>No ads!</strong>"
     free: "Free"
     month: "month"
     must_be_logged: "You must be logged in first. Please create an account or log in from the menu above."
diff --git a/app/models/User.coffee b/app/models/User.coffee
index 356fa46fe..8ea24ea5c 100644
--- a/app/models/User.coffee
+++ b/app/models/User.coffee
@@ -77,7 +77,7 @@ module.exports = class User extends CocoModel
 
   # y = a * ln(1/b * (x + c)) + 1
   @levelFromExp: (xp) ->
-    if xp > 0 then Math.floor(a * Math.log((1/b) * (xp + c))) + 1 else 1
+    if xp > 0 then Math.floor(a * Math.log((1 / b) * (xp + c))) + 1 else 1
 
   # x = b * e^((y-1)/a) - c
   @expForLevel: (level) ->
@@ -137,6 +137,16 @@ module.exports = class User extends CocoModel
     application.tracker.identify announcesActionAudioGroup: @announcesActionAudioGroup unless me.isAdmin()
     @announcesActionAudioGroup
 
+  getCampaignAdsGroup: ->
+    return @campaignAdsGroup if @campaignAdsGroup
+    group = me.get('testGroupNumber') % 2
+    @campaignAdsGroup = switch group
+      when 0 then 'no-ads'
+      when 1 then 'leaderboard-ads'
+    @campaignAdsGroup = 'no-ads' if me.isAdmin()
+    application.tracker.identify campaignAdsGroup: @campaignAdsGroup unless me.isAdmin()
+    @campaignAdsGroup
+
   getHomepageGroup: ->
     # Only testing on en-US so localization issues are not a factor
     return 'new-home-student' unless _.string.startsWith(me.get('preferredLanguage', true) or 'en-US', 'en')
diff --git a/app/styles/modal/subscribe-modal.sass b/app/styles/modal/subscribe-modal.sass
index 1d588e8f2..4a9efb066 100644
--- a/app/styles/modal/subscribe-modal.sass
+++ b/app/styles/modal/subscribe-modal.sass
@@ -111,7 +111,7 @@
         text-align: center
       tr
         td
-          padding: 3px
+          padding: 2px
           border-width: 0px
           border-top-width: 1px
           border-color: rgba(85, 85, 85, 0.1)
diff --git a/app/styles/play/campaign-view.sass b/app/styles/play/campaign-view.sass
index 8fbd22a7f..fd2bddcd0 100644
--- a/app/styles/play/campaign-view.sass
+++ b/app/styles/play/campaign-view.sass
@@ -628,6 +628,14 @@ $gameControlMargin: 30px
     img
       height: 30px
 
+  .ad-container
+    width: 100%
+    height: 90px
+    text-align: center
+
+  .gameplay-container
+    position: absolute
+
 body.ipad #campaign-view
   // iPad only supports up to Kithgard Gates for now.
   .campaign-switch
diff --git a/app/styles/play/level.sass b/app/styles/play/level.sass
index ad2a515c5..fb3f236bc 100644
--- a/app/styles/play/level.sass
+++ b/app/styles/play/level.sass
@@ -66,6 +66,7 @@ $level-resize-transition-time: 0.5s
 
   .level-content
     position: relative
+    background-color: black
 
   #canvas-wrapper
     top: 50px
@@ -83,14 +84,14 @@ $level-resize-transition-time: 0.5s
   canvas#webgl-surface
     background-color: #333
     z-index: 1
-    
+
   canvas#normal-surface
     z-index: 1
     position: absolute
     top: 0
     left: 0
     pointer-events: none
-    
+
   canvas#webgl-surface, canvas#normal-surface
     display: block
     z-index: 2
@@ -259,6 +260,10 @@ $level-resize-transition-time: 0.5s
     right: 15px
     font-size: 30px
 
+  .ad-container
+    width: 100%
+    height: 90px
+    text-align: center
 
 html.fullscreen-editor
   #level-view
diff --git a/app/templates/core/subscribe-modal.jade b/app/templates/core/subscribe-modal.jade
index fec7039f7..b0eca0241 100644
--- a/app/templates/core/subscribe-modal.jade
+++ b/app/templates/core/subscribe-modal.jade
@@ -68,18 +68,26 @@
               span.glyphicon.glyphicon-ok
           tr
             td.feature-description
-              span(data-i18n="subscribe.feature6")
+              span(data-i18n="[html]subscribe.feature7")
             if !me.isOnPremiumServer()
               td.free-cell
             td.center-ok
               span.glyphicon.glyphicon-ok
           tr
             td.feature-description
-              span(data-i18n="[html]subscribe.feature7")
+              span(data-i18n="subscribe.feature6")
             if !me.isOnPremiumServer()
               td.free-cell
             td.center-ok
               span.glyphicon.glyphicon-ok
+          if me.getCampaignAdsGroup() === 'leaderboard-ads'
+            tr
+              td.feature-description
+                span(data-i18n="[html]subscribe.feature8")
+              if !me.isOnPremiumServer()
+                td.free-cell
+              td.center-ok
+                span.glyphicon.glyphicon-ok
       #parents-info(data-i18n="subscribe.parents")
       #payment-methods-info(data-i18n="subscribe.payment_methods")
 
diff --git a/app/templates/play/campaign-view.jade b/app/templates/play/campaign-view.jade
index b2b0aa87b..96b3bfe32 100644
--- a/app/templates/play/campaign-view.jade
+++ b/app/templates/play/campaign-view.jade
@@ -1,165 +1,182 @@
-a(href="/").picoctf-hide
-  img.small-nav-logo(src="/images/pages/base/logo.png", title="CodeCombat - Learn how to code by playing a game", alt="CodeCombat")
-
-.picoctf-show
-  a(href="http://staging.picoctf.com").picoctf-logo
-    img.small-nav-logo(src="http://picoctf.com/img/2014_logo_blue2.svg", title="picoCTF home", alt="picoCTF home")
-  a(href="http://codecombat.com").picoctf-powered-by
-    em.spr powered by
-    img(src="/images/pages/base/logo.png", title="Powered by CodeCombat - Learn how to code by playing a game ", alt="Powered by CodeCombat")
-
-if campaign
-  .map
-    .gradient.horizontal-gradient.top-gradient
-    .gradient.vertical-gradient.right-gradient
-    .gradient.horizontal-gradient.bottom-gradient
-    .gradient.vertical-gradient.left-gradient
-    .map-background(alt="", draggable="false")
-
-    each level in levels
-      if !level.hidden
-        div(style="left: #{level.position.x}%; bottom: #{level.position.y}%; background-color: #{level.color}", class="level" + (level.next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + (levelStatusMap[level.slug] || ""), data-level-slug=level.slug, data-level-original=level.original, title=i18n(level, 'name') + (level.disabled ? ' (Coming Soon to Adventurers)' : ''))
-          if level.unlocksHero && (!level.purchasedHero || editorMode)
-            img.hero-portrait(src="/file/db/thang.type/#{level.unlocksHero}/portrait.png")
-          a(href=level.type == 'hero' ? '#' : level.disabled ? "/play" : "/play/#{level.levelPath || 'level'}/#{level.slug}", disabled=level.disabled, data-level-slug=level.slug, data-level-path=level.levelPath || 'level', data-level-name=level.name)
-          if level.slug == 'lost-viking'
-            img.star(src="/file/db/thang.type/5441c3144e9aeb727cc97111/portrait.png")
-          else if level.requiresSubscription
-            img.star(src="/images/pages/play/star.png")
-          if levelStatusMap[level.slug] === 'complete'
-            img.banner(src="/images/pages/play/level-banner-complete.png")
-          if levelStatusMap[level.slug] === 'started'
-            img.banner(src="/images/pages/play/level-banner-started.png")
-          if levelDifficultyMap[level.slug]
-            .level-difficulty-banner-text= levelDifficultyMap[level.slug]
-        div(style="left: #{level.position.x}%; bottom: #{level.position.y}%", class="level-shadow" + (level.next ? " next" : "") + " " + (levelStatusMap[level.slug] || ""))
-        .level-info-container(data-level-slug=level.slug, data-level-path=level.levelPath || 'level', data-level-name=level.name)
-          - var playCount = levelPlayCountMap[level.slug]
-          .progress.progress-striped.active.hide
-            .progress-bar(style="width: 100%")
-          - var showsLeaderboard = levelStatusMap[level.slug] === 'complete' && ((level.scoreTypes && level.scoreTypes.length) || ['hero-ladder', 'course-ladder'].indexOf(level.type) !== -1);
-
-          div(class="level-info " + (levelStatusMap[level.slug] || "") + (level.requiresSubscription ? " premium" : "") + (showsLeaderboard ? " shows-leaderboard" : ""))
-            .level-status
-            h3= i18n(level, 'name') + (level.disabled ? " (Coming soon!)" : (level.locked ? " (Locked)" : ""))
-            - var description = i18n(level, 'description') || level.description || ""
-            .level-description!= marked(description, {sanitize: !picoCTF})
-            if level.disabled
-              p
-                span.spr(data-i18n="play.awaiting_levels_adventurer_prefix") We release five levels per week.
-                a.spr(href="/contribute/adventurer")
-                  strong(data-i18n="play.awaiting_levels_adventurer") Sign up as an Adventurer
-                span.spl(data-i18n="play.awaiting_levels_adventurer_suffix") to be the first to play new levels.
-            if level.displayConcepts && level.displayConcepts.length
-              p
-                for concept in level.displayConcepts
-                  kbd(data-i18n="concepts." + concept)
-
-            if !level.disabled && !level.locked
-              if playCount && playCount.sessions
-                .play-counts.hidden
-                  span.spl.spr= playCount.sessions
-                  span(data-i18n="play.players") players
-                  span.spr , #{Math.round(playCount.playtime / 3600)}
-                  span(data-i18n="play.hours_played") hours played
-              if showsLeaderboard
-                button.btn.btn-warning.btn.btn-lg.btn-illustrated.view-solutions(data-level-slug=level.slug)
-                  span(data-i18n="leaderboard.scores")
-              button.btn.btn-success.btn.btn-lg.btn-illustrated.start-level(data-i18n="common.play") Play
-              if me.get('courseInstances') && me.get('courseInstances').length
-                .course-version.hidden(data-level-original=level.original)
-                  em(data-i18n="general.or")
-                  | ...
-                  br
-                  button.btn.btn-primary.btn.btn-lg.btn-illustrated
-                    span(data-i18n="play.play_classroom_version") Play Classroom Version
-      else if level.unlocksHero && !level.purchasedHero
-        img.hero-portrait(src="/file/db/thang.type/#{level.unlocksHero}/portrait.png", style="left: #{level.position.x}%; bottom: #{level.position.y}%;")
-
-    for adjacentCampaign in adjacentCampaigns
-      a(href=(editorMode ? "/editor/campaign/" : "/play/") + adjacentCampaign.slug)
-        span.glyphicon.glyphicon-share-alt.campaign-switch(style=adjacentCampaign.style, title=adjacentCampaign.name, data-campaign-id=adjacentCampaign.id)
-
-else
-  .portal
-    .portals
-      for campaignSlug in ['dungeon', 'forest', 'desert', 'mountain', 'glacier', 'volcano']
-        - var campaign = campaigns[campaignSlug];
-        - var godmode = me.get('permissions', true).indexOf('godmode') != -1;
-        div(class="campaign #{campaignSlug}" + (campaign ? "" : " silhouette") + (campaign && campaign.locked && !godmode ? " locked" : ""), data-campaign-slug=campaignSlug)
-          .campaign-label
-            h2.campaign-name
-              if campaign
-                span= i18n(campaign.attributes, 'fullName')
-              else
-                span ???
-            if campaign && campaign.levelsTotal
-              h3.levels-completed
-                span= campaign.levelsCompleted
-                | /
-                span= campaign.levelsTotal
-            if campaign && campaign.locked && !godmode
-              h3.campaign-locked(data-i18n="play.locked") Locked
-            else if campaign
-              btn(data-i18n="common.play").btn.btn-illustrated.btn-lg.btn-success.play-button
-            if campaign && campaign.get('description')
-              p.campaign-description
-                span= i18n(campaign.attributes, 'description')
-
-.game-controls.header-font.picoctf-hide
-  button.btn.poll.hidden(data-i18n="[title]play.poll")
-  a.btn.clans(href="/clans", data-i18n="[title]clans.clans")
-  button.btn.items(data-toggle='coco-modal', data-target='play/modal/PlayItemsModal', data-i18n="[title]play.items")
-  button.btn.heroes(data-toggle='coco-modal', data-target='play/modal/PlayHeroesModal', data-i18n="[title]play.heroes")
-  button.btn.achievements(data-toggle='coco-modal', data-target='play/modal/PlayAchievementsModal', data-i18n="[title]play.achievements")
-  if me.get('anonymous') === false || me.get('iosIdentifierForVendor') || isIPadApp
-    button.btn.gems(data-toggle='coco-modal', data-target='play/modal/BuyGemsModal', data-i18n="[title]play.buy_gems")
-  if !me.get('anonymous', true)
-    button.btn.account(data-toggle='coco-modal', data-target='play/modal/PlayAccountModal', data-i18n="[title]play.account")
-  //if me.isAdmin()
-  //  button.btn.settings(data-toggle='coco-modal', data-target='play/modal/PlaySettingsModal', data-i18n="[title]play.settings")
-  if me.get('anonymous', true)
-    button.btn.settings(data-toggle='coco-modal', data-target='core/CreateAccountModal', data-i18n="[title]play.settings")
-
-.user-status.header-font.picoctf-hide
-  .user-status-line
-    span.gem.gem-30
-    span#gems-count.spr= me.gems()
-    span.level-indicator(data-i18n="general.player_level")
-    span.player-level.spr= me.level()
-    span.player-hero-icon
-    if me.get('anonymous')
-      span.player-name.spr(data-i18n="play.anonymous") Anonymous Player
-      button.btn.btn-illustrated.login-button.btn-warning(data-i18n="login.log_in")
-      button.btn.btn-illustrated.signup-button.btn-danger(data-i18n="signup.sign_up")
+if view.showAds()
+  // TODO: loading this multiple times yields script error:
+  // Uncaught TagError: adsbygoogle.push() error: All ins elements in the DOM with class=adsbygoogle already have ads in them.
+  .ad-container
+    if campaign
+      script(async, src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js")
+      ins.adsbygoogle(style="display:inline-block;width:728px;height:90px", data-ad-client="ca-pub-6640930638193614", data-ad-slot="4924994487")
+      script.
+        (adsbygoogle = window.adsbygoogle || []).push({});
     else
-      a(data-toggle="coco-modal", data-target="play/modal/PlayAccountModal").player-name.spr= me.get('name')
-      button#logout-button.btn.btn-illustrated.btn-warning(data-i18n="login.log_out") Log Out
-      if me.isPremium()
-        button.btn.btn-illustrated.btn-primary(data-i18n="nav.contact", data-toggle="coco-modal", data-target="core/ContactModal") Contact
+      script(async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js")
+      ins.adsbygoogle(style="display:inline-block;width:728px;height:90px", data-ad-client="ca-pub-6640930638193614", data-ad-slot="4469166082")
+      script.
+        (adsbygoogle = window.adsbygoogle || []).push({});
 
-button.btn.btn-lg.btn-inverse.campaign-control-button.picoctf-hide#volume-button(data-i18n="[title]play.adjust_volume", title="Adjust volume")
-  .glyphicon.glyphicon-volume-off
-  .glyphicon.glyphicon-volume-down
-  .glyphicon.glyphicon-volume-up
+// TODO: .gameplay-container causes world map buttons to briefly appear in top left of screen
+.gameplay-container
+  a(href="/").picoctf-hide
+    img.small-nav-logo(src="/images/pages/base/logo.png", title="CodeCombat - Learn how to code by playing a game", alt="CodeCombat")
 
-if campaign && !editorMode
-  button.btn.btn-lg.btn-inverse.campaign-control-button.picoctf-hide#back-button(data-i18n="[title]resources.campaigns", title="Campaigns")
-    .glyphicon.glyphicon-globe
+  .picoctf-show
+    a(href="http://staging.picoctf.com").picoctf-logo
+      img.small-nav-logo(src="http://picoctf.com/img/2014_logo_blue2.svg", title="picoCTF home", alt="picoCTF home")
+    a(href="http://codecombat.com").picoctf-powered-by
+      em.spr powered by
+      img(src="/images/pages/base/logo.png", title="Powered by CodeCombat - Learn how to code by playing a game ", alt="Powered by CodeCombat")
 
-if editorMode
-  button.btn.btn-lg.btn-inverse.campaign-control-button#clear-storage-button(data-i18n="[title]editor.clear_storage", title="Clear your local changes")
-    .glyphicon.glyphicon-refresh
+  if campaign
+    .map
+      .gradient.horizontal-gradient.top-gradient
+      .gradient.vertical-gradient.right-gradient
+      .gradient.horizontal-gradient.bottom-gradient
+      .gradient.vertical-gradient.left-gradient
+      .map-background(alt="", draggable="false")
 
-if campaign && campaign.loaded
-  h1#campaign-status.picoctf-hide
-    .campaign-status-background
-      .campaign-name
-        - var fullName = i18n(campaign.attributes, 'fullName')
-        if (me.get('preferredLanguage', true) || 'en-US').split('-')[0] == 'en' || fullName != campaign.get('fullName')
-          // We have a translation.
-          span= fullName
-      .levels-completed
-        span= levelsCompleted
-        | /
-        span= levelsTotal
+      each level in levels
+        if !level.hidden
+          div(style="left: #{level.position.x}%; bottom: #{level.position.y}%; background-color: #{level.color}", class="level" + (level.next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + (levelStatusMap[level.slug] || ""), data-level-slug=level.slug, data-level-original=level.original, title=i18n(level, 'name') + (level.disabled ? ' (Coming Soon to Adventurers)' : ''))
+            if level.unlocksHero && (!level.purchasedHero || editorMode)
+              img.hero-portrait(src="/file/db/thang.type/#{level.unlocksHero}/portrait.png")
+            a(href=level.type == 'hero' ? '#' : level.disabled ? "/play" : "/play/#{level.levelPath || 'level'}/#{level.slug}", disabled=level.disabled, data-level-slug=level.slug, data-level-path=level.levelPath || 'level', data-level-name=level.name)
+            if level.slug == 'lost-viking'
+              img.star(src="/file/db/thang.type/5441c3144e9aeb727cc97111/portrait.png")
+            else if level.requiresSubscription
+              img.star(src="/images/pages/play/star.png")
+            if levelStatusMap[level.slug] === 'complete'
+              img.banner(src="/images/pages/play/level-banner-complete.png")
+            if levelStatusMap[level.slug] === 'started'
+              img.banner(src="/images/pages/play/level-banner-started.png")
+            if levelDifficultyMap[level.slug]
+              .level-difficulty-banner-text= levelDifficultyMap[level.slug]
+          div(style="left: #{level.position.x}%; bottom: #{level.position.y}%", class="level-shadow" + (level.next ? " next" : "") + " " + (levelStatusMap[level.slug] || ""))
+          .level-info-container(data-level-slug=level.slug, data-level-path=level.levelPath || 'level', data-level-name=level.name)
+            - var playCount = levelPlayCountMap[level.slug]
+            .progress.progress-striped.active.hide
+              .progress-bar(style="width: 100%")
+            - var showsLeaderboard = levelStatusMap[level.slug] === 'complete' && ((level.scoreTypes && level.scoreTypes.length) || ['hero-ladder', 'course-ladder'].indexOf(level.type) !== -1);
+
+            div(class="level-info " + (levelStatusMap[level.slug] || "") + (level.requiresSubscription ? " premium" : "") + (showsLeaderboard ? " shows-leaderboard" : ""))
+              .level-status
+              h3= i18n(level, 'name') + (level.disabled ? " (Coming soon!)" : (level.locked ? " (Locked)" : ""))
+              - var description = i18n(level, 'description') || level.description || ""
+              .level-description!= marked(description, {sanitize: !picoCTF})
+              if level.disabled
+                p
+                  span.spr(data-i18n="play.awaiting_levels_adventurer_prefix") We release five levels per week.
+                  a.spr(href="/contribute/adventurer")
+                    strong(data-i18n="play.awaiting_levels_adventurer") Sign up as an Adventurer
+                  span.spl(data-i18n="play.awaiting_levels_adventurer_suffix") to be the first to play new levels.
+              if level.displayConcepts && level.displayConcepts.length
+                p
+                  for concept in level.displayConcepts
+                    kbd(data-i18n="concepts." + concept)
+
+              if !level.disabled && !level.locked
+                if playCount && playCount.sessions
+                  .play-counts.hidden
+                    span.spl.spr= playCount.sessions
+                    span(data-i18n="play.players") players
+                    span.spr , #{Math.round(playCount.playtime / 3600)}
+                    span(data-i18n="play.hours_played") hours played
+                if showsLeaderboard
+                  button.btn.btn-warning.btn.btn-lg.btn-illustrated.view-solutions(data-level-slug=level.slug)
+                    span(data-i18n="leaderboard.scores")
+                button.btn.btn-success.btn.btn-lg.btn-illustrated.start-level(data-i18n="common.play") Play
+                if me.get('courseInstances') && me.get('courseInstances').length
+                  .course-version.hidden(data-level-original=level.original)
+                    em(data-i18n="general.or")
+                    | ...
+                    br
+                    button.btn.btn-primary.btn.btn-lg.btn-illustrated
+                      span(data-i18n="play.play_classroom_version") Play Classroom Version
+        else if level.unlocksHero && !level.purchasedHero
+          img.hero-portrait(src="/file/db/thang.type/#{level.unlocksHero}/portrait.png", style="left: #{level.position.x}%; bottom: #{level.position.y}%;")
+
+      for adjacentCampaign in adjacentCampaigns
+        a(href=(editorMode ? "/editor/campaign/" : "/play/") + adjacentCampaign.slug)
+          span.glyphicon.glyphicon-share-alt.campaign-switch(style=adjacentCampaign.style, title=adjacentCampaign.name, data-campaign-id=adjacentCampaign.id)
+
+  else
+    .portal
+      .portals
+        for campaignSlug in ['dungeon', 'forest', 'desert', 'mountain', 'glacier', 'volcano']
+          - var campaign = campaigns[campaignSlug];
+          - var godmode = me.get('permissions', true).indexOf('godmode') != -1;
+          div(class="campaign #{campaignSlug}" + (campaign ? "" : " silhouette") + (campaign && campaign.locked && !godmode ? " locked" : ""), data-campaign-slug=campaignSlug)
+            .campaign-label
+              h2.campaign-name
+                if campaign
+                  span= i18n(campaign.attributes, 'fullName')
+                else
+                  span ???
+              if campaign && campaign.levelsTotal
+                h3.levels-completed
+                  span= campaign.levelsCompleted
+                  | /
+                  span= campaign.levelsTotal
+              if campaign && campaign.locked && !godmode
+                h3.campaign-locked(data-i18n="play.locked") Locked
+              else if campaign
+                btn(data-i18n="common.play").btn.btn-illustrated.btn-lg.btn-success.play-button
+              if campaign && campaign.get('description')
+                p.campaign-description
+                  span= i18n(campaign.attributes, 'description')
+
+  .game-controls.header-font.picoctf-hide
+    button.btn.poll.hidden(data-i18n="[title]play.poll")
+    a.btn.clans(href="/clans", data-i18n="[title]clans.clans")
+    button.btn.items(data-toggle='coco-modal', data-target='play/modal/PlayItemsModal', data-i18n="[title]play.items")
+    button.btn.heroes(data-toggle='coco-modal', data-target='play/modal/PlayHeroesModal', data-i18n="[title]play.heroes")
+    button.btn.achievements(data-toggle='coco-modal', data-target='play/modal/PlayAchievementsModal', data-i18n="[title]play.achievements")
+    if me.get('anonymous') === false || me.get('iosIdentifierForVendor') || isIPadApp
+      button.btn.gems(data-toggle='coco-modal', data-target='play/modal/BuyGemsModal', data-i18n="[title]play.buy_gems")
+    if !me.get('anonymous', true)
+      button.btn.account(data-toggle='coco-modal', data-target='play/modal/PlayAccountModal', data-i18n="[title]play.account")
+    //if me.isAdmin()
+    //  button.btn.settings(data-toggle='coco-modal', data-target='play/modal/PlaySettingsModal', data-i18n="[title]play.settings")
+    if me.get('anonymous', true)
+      button.btn.settings(data-toggle='coco-modal', data-target='core/CreateAccountModal', data-i18n="[title]play.settings")
+
+  .user-status.header-font.picoctf-hide
+    .user-status-line
+      span.gem.gem-30
+      span#gems-count.spr= me.gems()
+      span.level-indicator(data-i18n="general.player_level")
+      span.player-level.spr= me.level()
+      span.player-hero-icon
+      if me.get('anonymous')
+        span.player-name.spr(data-i18n="play.anonymous") Anonymous Player
+        button.btn.btn-illustrated.login-button.btn-warning(data-i18n="login.log_in")
+        button.btn.btn-illustrated.signup-button.btn-danger(data-i18n="signup.sign_up")
+      else
+        a(data-toggle="coco-modal", data-target="play/modal/PlayAccountModal").player-name.spr= me.get('name')
+        button#logout-button.btn.btn-illustrated.btn-warning(data-i18n="login.log_out") Log Out
+        if me.isPremium()
+          button.btn.btn-illustrated.btn-primary(data-i18n="nav.contact", data-toggle="coco-modal", data-target="core/ContactModal") Contact
+
+  button.btn.btn-lg.btn-inverse.campaign-control-button.picoctf-hide#volume-button(data-i18n="[title]play.adjust_volume", title="Adjust volume")
+    .glyphicon.glyphicon-volume-off
+    .glyphicon.glyphicon-volume-down
+    .glyphicon.glyphicon-volume-up
+
+  if campaign && !editorMode
+    button.btn.btn-lg.btn-inverse.campaign-control-button.picoctf-hide#back-button(data-i18n="[title]resources.campaigns", title="Campaigns")
+      .glyphicon.glyphicon-globe
+
+  if editorMode
+    button.btn.btn-lg.btn-inverse.campaign-control-button#clear-storage-button(data-i18n="[title]editor.clear_storage", title="Clear your local changes")
+      .glyphicon.glyphicon-refresh
+
+  if campaign && campaign.loaded
+    h1#campaign-status.picoctf-hide
+      .campaign-status-background
+        .campaign-name
+          - var fullName = i18n(campaign.attributes, 'fullName')
+          if (me.get('preferredLanguage', true) || 'en-US').split('-')[0] == 'en' || fullName != campaign.get('fullName')
+            // We have a translation.
+            span= fullName
+        .levels-completed
+          span= levelsCompleted
+          | /
+          span= levelsTotal
diff --git a/app/templates/play/level.jade b/app/templates/play/level.jade
index 02bc0c9b7..5bf5a8efe 100644
--- a/app/templates/play/level.jade
+++ b/app/templates/play/level.jade
@@ -1,49 +1,59 @@
-#level-loading-view
+if view.showAds() 
+  // TODO: loading this multiple times yields script error:
+  // Uncaught TagError: adsbygoogle.push() error: All ins elements in the DOM with class=adsbygoogle already have ads in them.
+  .ad-container
+    script(async, src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js")
+    ins.adsbygoogle(style="display:inline-block;width:728px;height:90px", data-ad-client="ca-pub-6640930638193614", data-ad-slot="5527096883")
+    script.
+      (adsbygoogle = window.adsbygoogle || []).push({});
 
-.level-content
-  #control-bar-view
+.game-container
+  #level-loading-view
 
-  #fullscreen-editor-background-screen(title="Click to minimize the code editor")
+  .level-content
+    #control-bar-view
 
-  #code-area
-    #code-area-gradient.gradient
-    #tome-view
+    #fullscreen-editor-background-screen(title="Click to minimize the code editor")
 
-  #game-area
+    #code-area
+      #code-area-gradient.gradient
+      #tome-view
 
-    #canvas-wrapper
-      canvas(width=924, height=589)#webgl-surface
-      canvas(width=924, height=589)#normal-surface
-      #ascii-surface
-      #canvas-left-gradient.gradient
-      #canvas-top-gradient.gradient
-      #goals-view
+    #game-area
 
-    #level-flags-view
+      #canvas-wrapper
+        canvas(width=924, height=589)#webgl-surface
+        canvas(width=924, height=589)#normal-surface
+        #ascii-surface
+        #canvas-left-gradient.gradient
+        #canvas-top-gradient.gradient
+        #goals-view
 
-    #gold-view
+      #level-flags-view
 
-    #problem-alert-view
+      #gold-view
 
-    #level-chat-view
+      #problem-alert-view
 
-    #multiplayer-status-view
+      #level-chat-view
 
-    #duel-stats-view
+      #multiplayer-status-view
 
-    #playback-view
+      #duel-stats-view
 
-    #thang-hud
+      #playback-view
 
-    #level-dialogue-view
+      #thang-hud
 
-  button.btn.btn-lg.btn-warning.banner.header-font#stop-real-time-playback-button(title="Stop real-time playback", data-i18n="play_level.skip") Skip
+      #level-dialogue-view
 
-#level-footer-shadow
-#level-footer-background
+    button.btn.btn-lg.btn-warning.banner.header-font#stop-real-time-playback-button(title="Stop real-time playback", data-i18n="play_level.skip") Skip
 
-if !me.get('anonymous')
-  #play-footer(class=me.isPremium() ? "premium" : "")
-    p(class='footer-link-text').picoctf-hide
-      a.contact-link(title='Send CodeCombat a message', tabindex=-1, data-i18n="nav.contact") Contact
+  #level-footer-shadow
+  #level-footer-background
+
+  if !me.get('anonymous')
+    #play-footer(class=me.isPremium() ? "premium" : "")
+      p(class='footer-link-text').picoctf-hide
+        a.contact-link(title='Send CodeCombat a message', tabindex=-1, data-i18n="nav.contact") Contact
 
diff --git a/app/templates/play/modal/buy-gems-modal.jade b/app/templates/play/modal/buy-gems-modal.jade
index c67c31ca0..261fa9e41 100644
--- a/app/templates/play/modal/buy-gems-modal.jade
+++ b/app/templates/play/modal/buy-gems-modal.jade
@@ -22,7 +22,7 @@
         .product
           h4.subscription-gem-amount x{{gems}} / mo
           h3(data-i18n="account.subscription")
-          if me.isPremium()
+          if me.hasSubscription()
             button.disabled.start-subscription-button.btn.btn-lg.btn-illustrated.btn-success
               | ✓ 
               span(data-i18n="account.subscribed")
diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee
index f2fbfee90..ffb0da0b6 100644
--- a/app/views/play/CampaignView.coffee
+++ b/app/views/play/CampaignView.coffee
@@ -266,6 +266,11 @@ module.exports = class CampaignView extends RootView
     authModal.mode = 'signup'
     @openModalView authModal
 
+  showAds: ->
+    if application.isProduction() && !me.isPremium() && !me.isTeacher() && !window.serverConfig.picoCTF
+      return me.getCampaignAdsGroup() is 'leaderboard-ads'
+    false
+
   annotateLevel: (level) ->
     level.position ?= { x: 10, y: 10 }
     level.locked = not me.ownsLevel level.original
@@ -554,6 +559,7 @@ module.exports = class CampaignView extends RootView
     aspectRatio = mapWidth / mapHeight
     pageWidth = @$el.width()
     pageHeight = @$el.height()
+    pageHeight -= adContainerHeight if adContainerHeight = $('.ad-container').outerHeight()
     widthRatio = pageWidth / mapWidth
     heightRatio = pageHeight / mapHeight
     # Make sure we can see the whole map, fading to background in one dimension.
diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee
index b9a4590e1..71c134ca8 100644
--- a/app/views/play/level/PlayLevelView.coffee
+++ b/app/views/play/level/PlayLevelView.coffee
@@ -148,6 +148,13 @@ module.exports = class PlayLevelView extends RootView
       application.tracker?.trackEvent 'Finished Level Load', category: 'Play Level', label: @levelID, level: @levelID, loadDuration: @loadDuration
       application.tracker?.trackTiming @loadDuration, 'Level Load Time', @levelID, @levelID
 
+  isCourseMode: -> @courseID and @courseInstanceID
+
+  showAds: ->
+    if application.isProduction() && !me.isPremium() && !me.isTeacher() && !window.serverConfig.picoCTF && !@isCourseMode()
+      return me.getCampaignAdsGroup() is 'leaderboard-ads'
+    false
+
   # CocoView overridden methods ###############################################
 
   getRenderData: ->
@@ -326,7 +333,13 @@ module.exports = class PlayLevelView extends RootView
   initSurface: ->
     webGLSurface = $('canvas#webgl-surface', @$el)
     normalSurface = $('canvas#normal-surface', @$el)
-    @surface = new Surface(@world, normalSurface, webGLSurface, thangTypes: @supermodel.getModels(ThangType), observing: @observing, playerNames: @findPlayerNames(), levelType: @level.get('type', true))
+    surfaceOptions =
+      thangTypes: @supermodel.getModels(ThangType)
+      observing: @observing
+      playerNames: @findPlayerNames()
+      levelType: @level.get('type', true)
+      stayVisible: @showAds()
+    @surface = new Surface(@world, normalSurface, webGLSurface, surfaceOptions)
     worldBounds = @world.getBounds()
     bounds = [{x: worldBounds.left, y: worldBounds.top}, {x: worldBounds.right, y: worldBounds.bottom}]
     @surface.camera.setBounds(bounds)
@@ -499,7 +512,8 @@ module.exports = class PlayLevelView extends RootView
         break
     Backbone.Mediator.publish 'tome:cast-spell', {}
 
-  onWindowResize: (e) => @endHighlight()
+  onWindowResize: (e) => 
+    @endHighlight()
 
   onDisableControls: (e) ->
     return if e.controls and not ('level' in e.controls)
@@ -535,7 +549,7 @@ module.exports = class PlayLevelView extends RootView
     @endHighlight()
     options = {level: @level, supermodel: @supermodel, session: @session, hasReceivedMemoryWarning: @hasReceivedMemoryWarning, courseID: @courseID, courseInstanceID: @courseInstanceID, world: @world}
     ModalClass = if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder'] then HeroVictoryModal else VictoryModal
-    ModalClass = CourseVictoryModal if @courseID and @courseInstanceID
+    ModalClass = CourseVictoryModal if @isCourseMode()
     ModalClass = PicoCTFVictoryModal if window.serverConfig.picoCTF
     victoryModal = new ModalClass(options)
     @openModalView(victoryModal)