diff --git a/app/assets/images/pages/front/play_web.jpg b/app/assets/images/pages/front/play_web.jpg
deleted file mode 100644
index 6ef8412da..000000000
Binary files a/app/assets/images/pages/front/play_web.jpg and /dev/null differ
diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee
index f4f442ff0..e57efd220 100644
--- a/app/lib/LevelLoader.coffee
+++ b/app/lib/LevelLoader.coffee
@@ -88,6 +88,8 @@ module.exports = class LevelLoader extends CocoClass
         @listenToOnce @opponentSession, 'sync', @loadDependenciesForSession
 
   loadDependenciesForSession: (session) ->
+    if me.id isnt session.get 'creator'
+      session.patch = session.save = -> console.error "Not saving session, since we didn't create it."
     if session is @session
       codeLanguage = session.get('codeLanguage') or me.get('aceConfig')?.language or 'python'
       modulePath = "vendor/aether-#{codeLanguage}"
@@ -98,6 +100,7 @@ module.exports = class LevelLoader extends CocoClass
           if e.id is modulePath
             @languageModuleResource.markLoaded()
             @stopListening application.moduleLoader
+      @addSessionBrowserInfo session
 
       # hero-ladder games require the correct session team in level:loaded
       team = @team ? @session.get('team')
@@ -134,6 +137,17 @@ module.exports = class LevelLoader extends CocoClass
     if _.size(@sessionDependenciesRegistered) is 2 and @checkAllWorldNecessitiesRegisteredAndLoaded()
       @onWorldNecessitiesLoaded()
 
+  addSessionBrowserInfo: (session) ->
+    return unless me.id is session.get 'creator'
+    return unless $.browser?
+    browser = {}
+    browser['desktop'] = $.browser.desktop if $.browser.desktop
+    browser['name'] = $.browser.name if $.browser.name
+    browser['platform'] = $.browser.platform if $.browser.platform
+    browser['version'] = $.browser.version if $.browser.version
+    session.set 'browser', browser
+    session.patch()
+
   consolidateFlagHistory: ->
     state = @session.get('state') ? {}
     myFlagHistory = _.filter state.flagHistory ? [], team: @session.get('team')
diff --git a/app/lib/world/world.coffee b/app/lib/world/world.coffee
index 0c3d3332c..538f4976a 100644
--- a/app/lib/world/world.coffee
+++ b/app/lib/world/world.coffee
@@ -360,7 +360,7 @@ module.exports = class World
     #console.log "... world serializing frames from", startFrame, "to", endFrame, "of", @totalFrames
     [transferableObjects, nontransferableObjects] = [0, 0]
     delete flag.processed for flag in @flagHistory
-    o = {totalFrames: @totalFrames, maxTotalFrames: @maxTotalFrames, frameRate: @frameRate, dt: @dt, victory: @victory, userCodeMap: {}, trackedProperties: {}, flagHistory: @flagHistory, difficulty: @difficulty}
+    o = {totalFrames: @totalFrames, maxTotalFrames: @maxTotalFrames, frameRate: @frameRate, dt: @dt, victory: @victory, userCodeMap: {}, trackedProperties: {}, flagHistory: @flagHistory, difficulty: @difficulty, scores: @getScores()}
     o.trackedProperties[prop] = @[prop] for prop in @trackedProperties or []
 
     for thangID, methods of @userCodeMap
@@ -467,7 +467,7 @@ module.exports = class World
             w.userCodeMap[thangID][methodName][aetherStateKey] = serializedAether[aetherStateKey]
     else
       w = new World o.userCodeMap, classMap
-    [w.totalFrames, w.maxTotalFrames, w.frameRate, w.dt, w.scriptNotes, w.victory, w.flagHistory, w.difficulty] = [o.totalFrames, o.maxTotalFrames, o.frameRate, o.dt, o.scriptNotes ? [], o.victory, o.flagHistory, o.difficulty]
+    [w.totalFrames, w.maxTotalFrames, w.frameRate, w.dt, w.scriptNotes, w.victory, w.flagHistory, w.difficulty, w.scores] = [o.totalFrames, o.maxTotalFrames, o.frameRate, o.dt, o.scriptNotes ? [], o.victory, o.flagHistory, o.difficulty, o.scores]
     w[prop] = val for prop, val of o.trackedProperties
 
     perf.t1 = now()
@@ -603,3 +603,10 @@ module.exports = class World
   teamForPlayer: (n) ->
     playableTeams = @playableTeams ? ['humans']
     playableTeams[n % playableTeams.length]
+
+  getScores: ->
+    time: @age
+    'damage-taken': @getSystem('Combat')?.damageTakenForTeam 'humans'
+    'damage-dealt': @getSystem('Combat')?.damageDealtForTeam 'humans'
+    'gold-collected': @getSystem('Inventory')?.teamGold.humans?.earned
+    'difficulty': @difficulty
diff --git a/app/locale/de-DE.coffee b/app/locale/de-DE.coffee
index c4fb82d47..3dc8033e9 100644
--- a/app/locale/de-DE.coffee
+++ b/app/locale/de-DE.coffee
@@ -78,7 +78,7 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription:
     awaiting_levels_adventurer_prefix: "Wir veröffentlichen fünf Levels pro Woche."
     awaiting_levels_adventurer: "Registriere dich als ein Abenteurer"
     awaiting_levels_adventurer_suffix: "sei der Erste, der neue Levels spielt."
-#    adjust_volume: "Adjust volume"
+    adjust_volume: "Lautstärke anpassen"
     choose_your_level: "Wähle dein Level" # The rest of this section is the old play view at /play-old and isn't very important.
     adventurer_prefix: "Du kannst zu jedem Level springen oder diskutiere die Level "
     adventurer_forum: "im Abenteurerforum"
@@ -157,10 +157,10 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription:
     date: "Datum"
     body: "Inhalt"
     version: "Version"
-#    pending: "Pending"
-#    accepted: "Accepted"
-#    rejected: "Rejected"
-#    withdrawn: "Withdrawn"
+    pending: "ausstehend"
+    accepted: "akzeptiert"
+    rejected: "abgelehnt"
+    withdrawn: "zurückgezogen"
     submitter: "Übermittler"
     submitted: "Übermittelt"
     commit_msg: "Übertrage Nachricht"
@@ -168,10 +168,10 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription:
     version_history: "Versionshistorie"
     version_history_for: "Versionsgeschichte für: "
     select_changes: "Wähle zwei Änderungen unten um den Unterschied sehen zu können."
-#    undo_prefix: "Undo"
-#    undo_shortcut: "(Ctrl+Z)"
+    undo_prefix: "Rückgängig"
+    undo_shortcut: "(Strg+Z)"
 #    redo_prefix: "Redo"
-#    redo_shortcut: "(Ctrl+Shift+Z)"
+    redo_shortcut: "(Strg+Umschalt+Z)"
     play_preview: "Spiele eine Vorschau des momentanen Levels"
     result: "Ergebnis"
     results: "Ergebnisse"
@@ -370,9 +370,9 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription:
     unsubscribe: "Abmelden"
 #    confirm_unsubscribe: "Confirm Unsubscribe"
 #    never_mind: "Never Mind, I Still Love You"
-#    thank_you_months_prefix: "Thank you for supporting us these last"
-#    thank_you_months_suffix: "months."
-#    thank_you: "Thank you for supporting CodeCombat."
+    thank_you_months_prefix: "Danke für deine Unterstützung in den letzten"
+    thank_you_months_suffix: "Monaten."
+    thank_you: "Danke das du CodeCombat unterstützt."
 #    sorry_to_see_you_go: "Sorry to see you go! Please let us know what we could have done better."
 #    unsubscribe_feedback_placeholder: "O, what have we done?"
     levels: "25 weitere level! Und 5 neue jede Woche!"
@@ -627,7 +627,7 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription:
 #    desert: "Desert"
     grassy: "Grasig"
     small: "Klein"
-#    large: "Large"
+    large: "Groß"
     fork_title: "Forke neue Version"
     fork_creating: "Erzeuge Fork..."
     generate_terrain: "Generiere Terrain"
@@ -648,7 +648,7 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription:
     level_tab_thangs_all: "Alle"
     level_tab_thangs_conditions: "Startbedingungen"
     level_tab_thangs_add: "Thangs hinzufügen"
-#    add_components: "Add Components"
+    add_components: "Kommentar hinzufügen"
 #    component_configs: "Component Configurations"
 #    config_thang: "Double click to configure a thang"
     delete: "Löschen"
diff --git a/app/locale/en.coffee b/app/locale/en.coffee
index c02762a02..bfd6d715a 100644
--- a/app/locale/en.coffee
+++ b/app/locale/en.coffee
@@ -340,6 +340,19 @@
     multiplayer_caption: "Play with friends!"
     auth_caption: "Save your progress."
 
+  leaderboard:
+    leaderboard: "Leaderboard"
+    view_other_solutions: "View Other Solutions"
+    top_solutions: "Top Solutions"
+    day: "Today"
+    week: "This Week"
+    all: "All-Time"
+    time: "Time"
+    damage_taken: "Damage Taken"
+    damage_dealt: "Damage Dealt"
+    difficulty: "Difficulty"
+    gold_collected: "Gold Collected"
+
   inventory:
     choose_inventory: "Equip Items"
     equipped_item: "Equipped"
@@ -490,6 +503,46 @@
     matt_title: "Programmer"
     matt_blurb: "Bicyclist"
 
+  teachers:
+    title: "CodeCombat for Teachers"
+    preparation_title: "Preparation"
+    preparation_1: "CodeCombat is free to play for the core level progression and does not require students to sign up. We encourage teachers to"
+    preparation_play_campaign: "play through the campaign"
+    preparation_2: "to try it out, but the only thing you absolutely need to do to be ready is ensure students have access to a computer."
+    preparation_3: "It is not necessary for teachers to be comfortable with computer science concepts for students to have fun learning with CodeCombat."
+    violent_title: "Is it violent?"
+    violent_1: "We get this from teachers a lot due to our name. Although CodeCombat does contain cartoon violence, there is nothing graphic in either the visuals or language."
+    violent_2: "If you are comfortable having your students play Angry Birds, you will be comfortable with CodeCombat."
+    for_girls_title: "Is it for girls?"
+    for_girls_1: "There are three game modes in CodeCombat: building, puzzles, and combat. We have intentionally designed each to appeal to both boys and girls and think that the building and puzzle levels especially differentiate the game from violent triple A titles that repel female players."
+    what_cover_title: "What do we cover?"
+    what_cover_1: "There are 20 levels in the Hour of Code tutorial that teach and reinforce 6 specific computer science concepts:"
+    what_cover_notation_1: "Formal notation"
+    what_cover_notation_2: "- builds an understanding of the importance of syntax in programming."
+    what_cover_methods_1: "Calling methods"
+    what_cover_methods_2: "- familiarizes students with the syntax of object-oriented method calls."
+    what_cover_parameters_1: "Parameters"
+    what_cover_parameters_2: "- trains how to pass parameters to functions."
+    what_cover_strings_1: "Strings"
+    what_cover_strings_2: "- teaches students about string notation and passing strings as parameters."
+    what_cover_loops_1: "Loops"
+    what_cover_loops_2: "- develops the abstraction of designing short programs with loops."
+    what_cover_variables_1: "Variables"
+    what_cover_variables_2: "- adds the skill of referencing values that change over time."
+    what_cover_2: "Students may continue past level 20, depending on their speed and interest, to learn two additional concepts in later levels:"
+    what_cover_logic_1: "Conditional logic"
+    what_cover_logic_2: "- when and how to use if/else to control in-game outcomes."
+    what_cover_input_1: "Handling player input"
+    what_cover_input_2: "- responding to input events to create a user interface."
+    sys_requirements_title: "System Requirements"
+    sys_requirements_1: "Because CodeCombat is a game, it is more intensive for computers to run smoothly than video or written tutorials. We have optimized it to run quickly on all modern browsers and on older machines so that everyone can play. That said, here are our suggestions for getting the most out of your Hour of Code experience:"
+    sys_requirements_2: "Use newer versions of Chrome or Firefox."
+    sys_requirements_3: "Although CodeCombat will work on browsers as old as IE9, the performance is not as good. Chrome is best."
+    sys_requirements_4: "Use newer computers."
+    sys_requirements_5: "Older computers, Chromebooks, and netbooks tend to have very few system resources, which makes for a less enjoyable experience. At least 2GB of RAM is required."
+    sys_requirements_6: "Allow players to wear headphones/earbuds to hear the audio."
+    sys_requirements_7: "We help players learn through voiceover and sound effects, which will make classrooms noisy and distracting."
+
   versions:
     save_version_title: "Save New Version"
     new_major_version: "New Major Version"
diff --git a/app/models/LevelSession.coffee b/app/models/LevelSession.coffee
index 3e2732eb2..80ad366c2 100644
--- a/app/models/LevelSession.coffee
+++ b/app/models/LevelSession.coffee
@@ -68,3 +68,22 @@ module.exports = class LevelSession extends CocoModel
     last = new Date(last) if _.isString last
     # Wait at least this long before allowing submit button active again.
     (last - new Date()) + 22 * 60 * 60 * 1000
+
+  recordScores: (scores, level) ->
+    state = @get 'state'
+    oldTopScores = state.topScores ? []
+    newTopScores = []
+    now = new Date()
+    for scoreType in level.get('scoreTypes') ? []
+      oldTopScore = _.find oldTopScores, type: scoreType
+      newScore = scores[scoreType]
+      unless newScore?
+        newTopScores.push oldTopScore
+        continue
+      newScore *= -1 if scoreType in ['time', 'damage-taken']  # Make it so that higher is better
+      if not oldTopScore? or newScore > oldTopScore.score
+        newTopScores.push type: scoreType, date: now, score: newScore
+      else
+        newTopScores.push oldTopScore
+    state.topScores = newTopScores
+    @set 'state', state
diff --git a/app/models/User.coffee b/app/models/User.coffee
index ee537e1ae..2dcc1dcc6 100644
--- a/app/models/User.coffee
+++ b/app/models/User.coffee
@@ -133,6 +133,21 @@ module.exports = class User extends CocoModel
     application.tracker.identify foreshadowsLevels: @foreshadowsLevels unless me.isAdmin()
     @foreshadowsLevels
 
+  getLeaderboardsGroup: ->
+    return @leaderboardsGroup if @leaderboardsGroup?
+    group = me.get('testGroupNumber') % 64
+    if group < 16
+      @leaderboardsGroup = 'always'
+    else if group < 32
+      @leaderboardsGroup = 'early'
+    else if group < 48
+      @leaderboardsGroup = 'late'
+    else
+      @leaderboardsGroup = 'never'
+    @leaderboardsGroup = 'always' if me.isAdmin()
+    application.tracker.identify leaderboardsGroup: @leaderboardsGroup unless me.isAdmin()
+    @leaderboardsGroup
+
   getVideoTutorialStylesIndex: (numVideos=0)->
     # A/B Testing video tutorial styles
     # Not a constant number of videos available (e.g. could be 0, 1, 3, or 4 currently)
diff --git a/app/schemas/models/level.coffee b/app/schemas/models/level.coffee
index cec974734..43a9e4e8e 100644
--- a/app/schemas/models/level.coffee
+++ b/app/schemas/models/level.coffee
@@ -19,6 +19,7 @@ defaultTasks = [
   'Publish.'
   'Choose level options like required/restricted gear.'
   'Create achievements, including unlocking next level.'
+  'Choose leaderboard score types.'
 
   'Playtest with a slow/tough hero.'
   'Playtest with a fast/weak hero.'
@@ -341,6 +342,9 @@ _.extend LevelSchema.properties,
     type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference'
   }}
   campaign: c.shortString title: 'Campaign', description: 'Which campaign this level is part of (like "desert").', format: 'hidden'  # Automatically set by campaign editor.
+  scoreTypes: c.array {title: 'Score Types', description: 'What metric to show leaderboards for.', uniqueItems: true},
+     c.shortString(title: 'Score Type', 'enum': ['time', 'damage-taken', 'damage-dealt', 'gold-collected', 'difficulty'])  # TODO: good version of LoC; total gear value.
+
 
 c.extendBasicProperties LevelSchema, 'level'
 c.extendSearchableProperties LevelSchema
diff --git a/app/schemas/models/level_session.coffee b/app/schemas/models/level_session.coffee
index 939d015ae..6942b8f6b 100644
--- a/app/schemas/models/level_session.coffee
+++ b/app/schemas/models/level_session.coffee
@@ -29,6 +29,8 @@ LevelSessionSchema = c.object
 
 _.extend LevelSessionSchema.properties,
   # denormalization
+  browser:
+    type: 'object'
   creatorName:
     type: 'string'
   levelName:
@@ -133,6 +135,12 @@ _.extend LevelSessionSchema.properties,
           x: {type: 'number'}
           y: {type: 'number'}
         source: {type: 'string', enum: ['click']}  # Do not store 'code' flag events in the session.
+    topScores: c.array {},
+      c.object {},
+        type: c.shortString('enum': ['time', 'damage-taken', 'damage-dealt', 'gold-collected', 'difficulty'])
+        date: c.date
+          description: 'When the submission achieving this score happened.'
+        score: {type: 'number'}  # Store 'time' and 'damage-taken' as negative numbers so the index works.
 
   code:
     type: 'object'
diff --git a/app/styles/legal.sass b/app/styles/legal.sass
index 309a16bfb..ce9983b54 100644
--- a/app/styles/legal.sass
+++ b/app/styles/legal.sass
@@ -4,6 +4,3 @@
     float: right
     width: 300px
     margin-left: 20px
-    
-  .cc-license-link
-    margin-left: 10px
\ No newline at end of file
diff --git a/app/styles/play/level/modal/hero-victory-modal.sass b/app/styles/play/level/modal/hero-victory-modal.sass
index c881afa9f..5fa59faa9 100644
--- a/app/styles/play/level/modal/hero-victory-modal.sass
+++ b/app/styles/play/level/modal/hero-victory-modal.sass
@@ -320,6 +320,11 @@
       margin: 0
       float: left
     
+  .leaderboard-button
+    height: 60px
+    line-height: 30px
+    margin: 0 10px
+    float: left
 
   .next-level-buttons
     float: right
diff --git a/app/styles/game-menu/game-menu-modal.sass b/app/styles/play/menu/game-menu-modal.sass
similarity index 100%
rename from app/styles/game-menu/game-menu-modal.sass
rename to app/styles/play/menu/game-menu-modal.sass
diff --git a/app/styles/game-menu/guide-view.sass b/app/styles/play/menu/guide-view.sass
similarity index 100%
rename from app/styles/game-menu/guide-view.sass
rename to app/styles/play/menu/guide-view.sass
diff --git a/app/styles/game-menu/inventory-modal.sass b/app/styles/play/menu/inventory-modal.sass
similarity index 100%
rename from app/styles/game-menu/inventory-modal.sass
rename to app/styles/play/menu/inventory-modal.sass
diff --git a/app/styles/game-menu/item-view.sass b/app/styles/play/menu/item-view.sass
similarity index 100%
rename from app/styles/game-menu/item-view.sass
rename to app/styles/play/menu/item-view.sass
diff --git a/app/styles/game-menu/multiplayer-view.sass b/app/styles/play/menu/multiplayer-view.sass
similarity index 100%
rename from app/styles/game-menu/multiplayer-view.sass
rename to app/styles/play/menu/multiplayer-view.sass
diff --git a/app/styles/game-menu/options-view.sass b/app/styles/play/menu/options-view.sass
similarity index 100%
rename from app/styles/game-menu/options-view.sass
rename to app/styles/play/menu/options-view.sass
diff --git a/app/styles/game-menu/save-load-view.sass b/app/styles/play/menu/save-load-view.sass
similarity index 100%
rename from app/styles/game-menu/save-load-view.sass
rename to app/styles/play/menu/save-load-view.sass
diff --git a/app/styles/play/modal/leaderboard-modal.sass b/app/styles/play/modal/leaderboard-modal.sass
new file mode 100644
index 000000000..a6736b873
--- /dev/null
+++ b/app/styles/play/modal/leaderboard-modal.sass
@@ -0,0 +1,94 @@
+@import "app/styles/bootstrap/variables"
+@import "app/styles/mixins"
+
+#leaderboard-modal
+
+  //- Clear modal defaults
+  
+  .modal-dialog
+    width: 820px
+    height: 570px
+    padding: 0
+    background: none
+    position: relative
+    top: 40px
+
+    
+  //- Background
+  
+  #leaderboard-background
+    position: absolute
+    top: -146px
+    left: -3px
+
+    
+  //- Close modal button
+
+  #close-modal
+    position: absolute
+    left: 769px
+    top: -5px
+    width: 60px
+    height: 60px
+    color: white
+    text-align: center
+    font-size: 30px
+    padding-top: 17px
+    cursor: pointer
+    z-index: 2
+    @include rotate(-3deg)
+
+    &:hover
+      color: yellow
+
+    
+  //- Nav bar
+
+  #leaderboard-nav
+    position: absolute
+    top: 53px
+    left: 42px
+    width: 178px
+
+    li
+      background: url(/images/pages/play/modal/menu-tab.png)
+      padding: 5px
+      margin: -5px 0
+      height: 80px
+      padding: 0
+      
+      &.active
+        background: url(/images/pages/play/modal/menu-tab-selected.png)
+        width: 197px
+
+      a
+        font-size: 18px
+        line-height: 25px
+        background: none
+        color: rgb(195,153,124)
+        font-weight: bold
+        padding: 14px 20px
+        font-family: $headings-font-family
+        text-transform: uppercase
+    
+        .timespan
+          margin-left: 20px
+          opacity: 0.75
+
+
+  //- Tab panels
+  
+  .leaderboard-tab-content
+    position: absolute
+    left: 219px
+    top: 21px
+    width: 571px
+    height: 514px
+    padding: 50px
+    overflow-y: scroll
+
+  ::-webkit-scrollbar
+    // So that the scrollbar doesn't go on top of the close button.
+    // Wish we could easily do this for Firefox.
+    display: none
+
diff --git a/app/styles/play/modal/leaderboard-tab-view.sass b/app/styles/play/modal/leaderboard-tab-view.sass
new file mode 100644
index 000000000..9a175295b
--- /dev/null
+++ b/app/styles/play/modal/leaderboard-tab-view.sass
@@ -0,0 +1,36 @@
+.leaderboard-tab-view
+
+  h1
+    margin-top: -20px
+    color: rgb(254,188,68)
+    font-size: 30px
+    text-shadow: black 2px 2px 0, black -2px -2px 0, black 2px -2px 0, black -2px 2px 0, black 2px 0px 0, black 0px -2px 0, black -2px 0px 0, black 0px 2px 0
+
+  table
+
+    td
+      padding: 1px 2px
+      font-size: 16px
+      text-align: center
+
+    th
+      text-align: center
+
+    tbody
+      tr.viewable
+        cursor: pointer
+
+      .rank-cell
+        font-weight: bold
+  
+      .name-col-cell
+        max-width: 150px
+        white-space: nowrap
+        overflow: hidden
+        text-overflow: ellipsis
+    
+      .hero-portrait-cell, .code-language-cell
+        background: transparent url(/images/common/code_languages/javascript_small.png) no-repeat center center
+        background-size: 30px 30px
+        height: 30px
+        width: 32px
diff --git a/app/templates/about.jade b/app/templates/about.jade
index a300bcd70..da669661d 100644
--- a/app/templates/about.jade
+++ b/app/templates/about.jade
@@ -68,8 +68,7 @@ block content
             p(data-i18n="about.george_blurb")
                 | Businesser
     
-          a(href="http://scotterickson.info")
-            img(src="/images/pages/about/scott_small.png").img-thumbnail
+          img(src="/images/pages/about/scott_small.png").img-thumbnail
   
           .team_bio
 
@@ -122,5 +121,3 @@ block content
               | Programmer
             p(data-i18n="about.matt_blurb")
               | Bicyclist
-
-
diff --git a/app/templates/legal.jade b/app/templates/legal.jade
index 9f2531ee3..0f96fa391 100644
--- a/app/templates/legal.jade
+++ b/app/templates/legal.jade
@@ -20,8 +20,7 @@ block content
       | dozens of open source projects, and we love them. See 
     a(href="https://github.com/codecombat/codecombat/wiki/Archmage-Home", data-i18n="legal.archmage_wiki_url")
       | our Archmage wiki
-    span  
-    span(data-i18n="legal.opensource_description_suffix")
+    span.spl(data-i18n="legal.opensource_description_suffix")
       | for a list of the software that makes this game possible.
   hr
 
@@ -43,13 +42,11 @@ block content
     h4(data-i18n="legal.email_title")
       | Email
     p
-      span(data-i18n="legal.email_description_prefix")
+      span.spr(data-i18n="legal.email_description_prefix")
         | We will not inundate you with spam. Through
-      span  
       a(href='/account/settings', data-i18n="legal.email_settings_url")
         | your email settings
-      span  
-      span(data-i18n="legal.email_description_suffix")
+      span.spl(data-i18n="legal.email_description_suffix")
         | or through links in the emails we send, 
         | you can change your preferences and easily unsubscribe at any time.
     h4(data-i18n="legal.cost_title")
@@ -64,9 +61,8 @@ block content
     | Contributor License Agreement
   
   p
-    span(data-i18n="legal.contributor_description_prefix")
+    span.spr(data-i18n="legal.contributor_description_prefix")
       | All contributions, both on the site and on our GitHub repository, are subject to our
-    span  
     a(href="/cla", data-i18n="legal.cla_url")
       | CLA
     span , 
@@ -77,11 +73,10 @@ block content
     | Code - MIT
   
   p
-    span(data-i18n="legal.code_description_prefix")
+    span.spr(data-i18n="legal.code_description_prefix")
       | All code owned by CodeCombat or hosted on codecombat.com, 
       | both in the GitHub repository or in the codecombat.com database, 
       | is licensed under the
-    span  
     a(href="http://opensource.org/licenses/MIT", data-i18n="legal.mit_license_url")
       | MIT license
     span . 
@@ -91,14 +86,11 @@ block content
   
   h3(data-i18n="legal.art_title")
     | Art/Music - Creative Commons 
-    a(rel='license', href='http://creativecommons.org/licenses/by/4.0/').cc-license-link
-      img(alt='Creative Commons License', style='border-width: 0; margin-left: 10px', src='http://i.creativecommons.org/l/by/4.0/88x31.png')
 
-  p 
-    span(data-i18n="legal.art_description_prefix")
+  p
+    span.spr(data-i18n="legal.art_description_prefix")
       | All common content is available under the
-    span  
-    a(href="http://creativecommons.org/licenses/by/4.0/", data-i18n="legal.cc_license_url")
+    a(href="https://creativecommons.org/licenses/by/4.0/", data-i18n="legal.cc_license_url")
       | Creative Commons Attribution 4.0 International License
     span . 
     span(data-i18n="legal.art_description_suffix")
diff --git a/app/templates/play/level/control_bar.jade b/app/templates/play/level/control_bar.jade
index 2d020e7c1..176778657 100644
--- a/app/templates/play/level/control_bar.jade
+++ b/app/templates/play/level/control_bar.jade
@@ -9,7 +9,7 @@
     .glyphicon.glyphicon-play
     span(data-i18n="nav.play").home-text Levels
 
-if isMultiplayerLevel
+if isMultiplayerLevel && !observing
   .multiplayer-area-container
     .multiplayer-area
       .multiplayer-label(data-i18n="play_level.control_bar_multiplayer")
@@ -28,17 +28,19 @@ else
 
 .buttons-area
 
-  button.btn.btn-inverse#game-menu-button(title="Show game menu")
-    .hamburger
-      span.icon-bar
-      span.icon-bar
-      span.icon-bar
-    span.game-menu-text(data-i18n="play_level.game_menu") Game Menu
+  if !observing
+    button.btn.btn-inverse#game-menu-button(title="Show game menu")
+      .hamburger
+        span.icon-bar
+        span.icon-bar
+        span.icon-bar
+      span.game-menu-text(data-i18n="play_level.game_menu") Game Menu
   
   if spectateGame
     button.btn.btn-xs.btn-inverse.banner#next-game-button(title="Next Game", data-i18n="play_level.next-game") Next game!
   
-  button.btn.btn-xs.btn-primary.banner#level-done-button(data-i18n="play_level.done") Done
+  if !observing
+    button.btn.btn-xs.btn-primary.banner#level-done-button(data-i18n="play_level.done") Done
 
   if me.get('anonymous')
     button.btn.btn-xs.btn-primary.banner#control-bar-sign-up-button(data-toggle='coco-modal', data-target='core/AuthModal', data-i18n="signup.sign_up")
diff --git a/app/templates/play/level/modal/hero-victory-modal.jade b/app/templates/play/level/modal/hero-victory-modal.jade
index 22fe126a1..2c13d42ba 100644
--- a/app/templates/play/level/modal/hero-victory-modal.jade
+++ b/app/templates/play/level/modal/hero-victory-modal.jade
@@ -68,6 +68,9 @@ block modal-footer-content
       .sign-up-blurb(data-i18n="play_level.victory_sign_up_poke") Want to save your code? Create a free account!
       button.btn.btn-illustrated.btn-warning.sign-up-button.btn-lg(data-dismiss="modal", data-i18n="play_level.victory_sign_up") Sign Up to Save Progress
 
+  else if !showHourOfCodeDoneButton && showLeaderboard
+    button.btn.btn-illustrated.btn-warning.leaderboard-button.btn-lg(data-dismiss="modal", data-i18n="leaderboard.view_other_solutions") View Other Solutions
+
   button.btn.btn-illustrated.btn-lg.btn-warning.hide#saving-progress-label(disabled, data-i18n="play_level.victory_saving_progress") Saving Progress
 
   .next-level-buttons
diff --git a/app/templates/play/level/tome/cast_button.jade b/app/templates/play/level/tome/cast_button.jade
index 7527f9ba6..a8f2470cd 100644
--- a/app/templates/play/level/tome/cast_button.jade
+++ b/app/templates/play/level/tome/cast_button.jade
@@ -1,9 +1,10 @@
 button.btn.btn-lg.btn-illustrated.cast-button(title=castVerbose)
   span(data-i18n="play_level.tome_run_button_ran") Ran
 
-button.btn.btn-lg.btn-illustrated.submit-button(title=castRealTimeVerbose)
-  span(data-i18n="play_level.tome_submit_button") Submit
-  span.spl.secret.submit-again-time
-
-button.btn.btn-lg.btn-illustrated.done-button.secret
-  span(data-i18n="play_level.done") Done
+if !observing
+  button.btn.btn-lg.btn-illustrated.submit-button(title=castRealTimeVerbose)
+    span(data-i18n="play_level.tome_submit_button") Submit
+    span.spl.secret.submit-again-time
+  
+  button.btn.btn-lg.btn-illustrated.done-button.secret
+    span(data-i18n="play_level.done") Done
diff --git a/app/templates/play/modal/leaderboard-modal.jade b/app/templates/play/modal/leaderboard-modal.jade
new file mode 100644
index 000000000..86192586f
--- /dev/null
+++ b/app/templates/play/modal/leaderboard-modal.jade
@@ -0,0 +1,18 @@
+.modal-dialog
+  .modal-content
+    img(src="/images/pages/play/modal/game-menu-background.png", draggable="false")#leaderboard-background
+
+    div#close-modal
+      span.glyphicon.glyphicon-remove
+
+    ul#leaderboard-nav.nav.nav-pills.nav-stacked
+      for submenu, index in submenus
+        li(class=index ? "" : "active")
+          a(href='#' + submenu.scoreType + '-' + submenu.timespan + '-view', data-toggle='tab')
+            .scoreType(data-i18n='leaderboard.' + submenu.scoreType.replace('-', '_'))= submenu.scoreType
+            .timespan(data-i18n='leaderboard.' + submenu.timespan)
+
+    .tab-content.leaderboard-tab-content
+      for submenu, index in submenus
+        .tab-pane(id=submenu.scoreType + '-' + submenu.timespan + '-view')
+          .leaderboard-tab-view
\ No newline at end of file
diff --git a/app/templates/play/modal/leaderboard-tab-view.jade b/app/templates/play/modal/leaderboard-tab-view.jade
new file mode 100644
index 000000000..6ed434fd4
--- /dev/null
+++ b/app/templates/play/modal/leaderboard-tab-view.jade
@@ -0,0 +1,34 @@
+h1
+  span.spr(data-i18n="leaderboard.top") Top Players by
+  span(data-i18n="leaderboard.#{scoreType.replace('-', '_')}")
+  span.spr ,
+  span(data-i18n="leaderboard.#{timespan}")
+
+if topScores
+  table.table.table-bordered.table-condensed.table-hover
+    thead
+      tr
+        th(colspan=4, data-i18n="general.player")
+        th(data-i18n="general.score")
+        th(data-i18n="general.when")
+        th
+    tbody
+      for row, rank in topScores
+        - var isMyRow = row.creator == me.id
+        - var viewable = rank >= 5 || me.isAdmin();
+        tr(class=isMyRow ? "success" : "" + (viewable ? " viewable" : ""), data-player-id=row.creator, data-session-id=row.session, title=viewable ? "View solution" : "Can't view top 5 solutions")
+          td.rank-cell= rank + 1
+          td.code-language-cell(style="background-image: url(/images/common/code_languages/#{row.codeLanguage}_small.png)" title=_.string.capitalize(row.codeLanguage))
+          td.hero-portrait-cell(style="background-image: url(/file/db/thang.type/#{row.hero}/portrait.png)")
+          td.name-col-cell= row.creatorName || "Anonymous"
+          td.score-cell= row.score
+          td.ago-cell= row.ago
+          td.viewable-cell
+            if viewable
+              .glyphicon.glyphicon-eye-open
+            else
+              .glyphicon.glyphicon-eye-close
+else if loading
+  h3(data-i18n="common.loading")
+else
+  h3 No scores yet.
\ No newline at end of file
diff --git a/app/templates/teachers.jade b/app/templates/teachers.jade
index b76d3b417..f7d22a456 100644
--- a/app/templates/teachers.jade
+++ b/app/templates/teachers.jade
@@ -6,69 +6,114 @@ block content
 
     .span5
 
-      h2 CodeCombat for Teachers
+      h2(data-i18n="teachers.title") CodeCombat for Teachers
 
-      h3 Preparation
+      h3(data-i18n="teachers.preparation_title") Preparation
 
-      p CodeCombat is free to play for the core level progression and does not require students to sign up. We encourage teachers to 
-        a(href="/play") play through the campaign
-        |  to try it out, but the only thing you absolutely need to do to be ready is ensure students have access to a computer.
+      p
+        span.spr(data-i18n="teachers.preparation_1")
+          | CodeCombat is free to play for the core level progression 
+          | and does not require students to sign up. We encourage teachers to
+        a(href="/play", data-i18n="teachers.preparation_play_campaign") play through the campaign
+        span.spl(data-i18n="teachers.preparation_2")
+          | to try it out, but the only thing you absolutely need to do 
+          | to be ready is ensure students have access to a computer.
 
-      p It is not necessary for teachers to be comfortable with computer science concepts for students to have fun learning with CodeCombat.
+      p(data-i18n="teachers.preparation_3")
+        | It is not necessary for teachers to be comfortable with computer 
+        | science concepts for students to have fun learning with CodeCombat.
 
-      h3 Is it violent?
+      h3(data-i18n="teachers.violent_title")
+        | Is it violent?
 
-      p We get this from teachers a lot due to our name. Although CodeCombat does contain cartoon violence, there is nothing graphic in either the visuals or language. If you are comfortable having your students play Angry Birds, you will be comfortable with CodeCombat.
+      p
+        span.spr(data-i18n="teachers.violent_1")
+          | We get this from teachers a lot due to our name. Although CodeCombat 
+          | does contain cartoon violence, there is nothing graphic in either the 
+          | visuals or language.
+        span(data-i18n="teachers.violent_2")
+          | If you are comfortable having your students play Angry Birds, you will 
+          | be comfortable with CodeCombat.
 
-      h3 Is it for girls?
+      h3(data-i18n="teachers.for_girls_title")
+        | Is it for girls?
 
-      p There are three game modes in CodeCombat: building, puzzles, and combat. We have intentionally designed each to appeal to both boys and girls and think that the building and puzzle levels especially differentiate the game from violent triple A titles that repel female players.
+      p(data-i18n="teachers.for_girls_1")
+        | There are three game modes in CodeCombat: building, puzzles, and combat. 
+        | We have intentionally designed each to appeal to both boys and girls and 
+        | think that the building and puzzle levels especially differentiate the game 
+        | from violent triple A titles that repel female players.
 
-      h3 What do we cover?
+      h3(data-i18n="teachers.what_cover_title")
+        | What do we cover?
 
-      p There are 20 levels in the Hour of Code tutorial that teach and reinforce 6 specific computer science concepts:
+      p(data-i18n="teachers.what_cover_1")
+        | There are 20 levels in the Hour of Code tutorial that teach and 
+        | reinforce 6 specific computer science concepts:
 
         ol
           li
-            strong Formal notation
-            |  - builds an understanding of the importance of syntax in programming.
+            strong.spr(data-i18n="teachers.what_cover_notation_1") Formal notation
+            span(data-i18n="teachers.what_cover_notation_2")
+              | - builds an understanding of the importance of syntax in programming.
           li
-            strong Calling methods
-            |  - familiarizes students with the syntax of object-oriented method calls.
+            strong.spr(data-i18n="teachers.what_cover_methods_1") Calling methods
+            span(data-i18n="teachers.what_cover_methods_2")
+              | - familiarizes students with the syntax of object-oriented method calls.
           li
-            strong Parameters
-            |  - trains how to pass parameters to functions.
+            strong.spr(data-i18n="teachers.what_cover_parameters_1") Parameters
+            span(data-i18n="teachers.what_cover_parameters_2")
+              | - trains how to pass parameters to functions.
           li
-            strong Strings
-            |  - teaches students about string notation and passing strings as parameters.
+            strong.spr(data-i18n="teachers.what_cover_strings_1") Strings
+            span(data-i18n="teachers.what_cover_strings_2")
+              | - teaches students about string notation and passing strings as parameters.
           li
-            strong Loops
-            |  - develops the abstraction of designing short programs with loops.
+            strong.spr(data-i18n="teachers.what_cover_loops_1") Loops
+            span(data-i18n="teachers.what_cover_loops_2")
+              | - develops the abstraction of designing short programs with loops.
           li
-            strong Variables
-            |  - adds the skill of referencing values that change over time.
+            strong.spr(data-i18n="teachers.what_cover_variables_1") Variables
+            span(data-i18n="teachers.what_cover_variables_2")
+              | - adds the skill of referencing values that change over time.
 
-      p Students may continue past level 20, depending on their speed and interest, to learn two additional concepts in later levels:
+      p(data-i18n="teachers.what_cover_2")
+        | Students may continue past level 20, depending on their 
+        | speed and interest, to learn two additional concepts in later levels:
         ol
           li
-            strong Conditional logic
-            |  - when and how to use if/else to control in-game outcomes.
+            strong.spr(data-i18n="teachers.what_cover_logic_1") Conditional logic
+            span(data-i18n="teachers.what_cover_logic_2")
+              | - when and how to use if/else to control in-game outcomes.
           li
-            strong Handling player input
-            |  - responding to input events to create a user interface.
+            strong.spr(data-i18n="teachers.what_cover_input_1") Handling player input
+            span(data-i18n="teachers.what_cover_input_2")
+              | - responding to input events to create a user interface.
 
 
-      h3 System Requirements
+      h3(data-i18n="teachers.sys_requirements_title") System Requirements
 
-      p Because CodeCombat is a game, it is more intensive for computers to run smoothly than video or written tutorials. We have optimized it to run quickly on all modern browsers and on older machines so that everyone can play. That said, here are our suggestions for getting the most out of your Hour of Code experience:
+      p(data-i18n="teachers.sys_requirements_1") 
+        | Because CodeCombat is a game, it is more intensive for computers 
+        | to run smoothly than video or written tutorials. We have optimized 
+        | it to run quickly on all modern browsers and on older machines so 
+        | that everyone can play. That said, here are our suggestions for getting 
+        | the most out of your Hour of Code experience:
 
       ul
         li
-          strong Use newer versions of Chrome or Firefox.
-          |  Although CodeCombat will work on browsers as old as IE9, the performance is not as good. Chrome is best.
+          strong.spr(data-i18n="teachers.sys_requirements_2") Use newer versions of Chrome or Firefox.
+          span(data-i18n="teachers.sys_requirements_3")
+            | Although CodeCombat will work on browsers as old as IE9, the 
+            | performance is not as good. Chrome is best.
         li
-          strong Use newer computers.
-          |  Older computers, Chromebooks, and netbooks tend to have very few system resources, which makes for a less enjoyable experience. At least 2GB of RAM is required.
+          strong.spr(data-i18n="teachers.sys_requirements_4") Use newer computers.
+          span(data-i18n="teachers.sys_requirements_5")
+            | Older computers, Chromebooks, and netbooks tend to have very few 
+            | system resources, which makes for a less enjoyable experience. 
+            | At least 2GB of RAM is required.
         li
-          strong Allow players to wear headphones/earbuds to hear the audio.
-          |  We help players learn through voiceover and sound effects, which will make classrooms noisy and distracting.
+          strong.spr(data-i18n="teachers.sys_requirements_6") Allow players to wear headphones/earbuds to hear the audio.
+          span(data-i18n="teachers.sys_requirements_7")
+            | We help players learn through voiceover and sound effects, which 
+            | will make classrooms noisy and distracting.
diff --git a/app/views/common/SearchView.coffee b/app/views/common/SearchView.coffee
index e0b24a533..a2c813a77 100644
--- a/app/views/common/SearchView.coffee
+++ b/app/views/common/SearchView.coffee
@@ -8,9 +8,9 @@ class SearchCollection extends Backbone.Collection
     @url = "#{modelURL}?project="
     if @projection?.length
       @url += 'created,permissions'
-      @url += ',' + projected for projected in projection
+      @url += ',' + projected for projected in @projection
     else @url += 'true'
-    @url += "&term=#{term}" if @term
+    @url += "&term=#{@term}" if @term
 
   comparator: (a, b) ->
     score = 0
diff --git a/app/views/editor/level/settings/SettingsTabView.coffee b/app/views/editor/level/settings/SettingsTabView.coffee
index ff5cd6eb4..5c7181824 100644
--- a/app/views/editor/level/settings/SettingsTabView.coffee
+++ b/app/views/editor/level/settings/SettingsTabView.coffee
@@ -15,7 +15,7 @@ module.exports = class SettingsTabView extends CocoView
   editableSettings: [
     'name', 'description', 'documentation', 'nextLevel', 'background', 'victory', 'i18n', 'icon', 'goals',
     'type', 'terrain', 'showsGuide', 'banner', 'employerDescription', 'loadingTip', 'requiresSubscription',
-    'tasks', 'helpVideos', 'replayable'
+    'tasks', 'helpVideos', 'replayable', 'scoreTypes'
   ]
 
   subscriptions:
diff --git a/app/views/editor/modal/VersionsModal.coffee b/app/views/editor/modal/VersionsModal.coffee
index d3e174947..8be2ca97c 100755
--- a/app/views/editor/modal/VersionsModal.coffee
+++ b/app/views/editor/modal/VersionsModal.coffee
@@ -12,7 +12,7 @@ class VersionsViewCollection extends CocoCollection
 
   initialize: (@url, @levelID, @model) ->
     super()
-    @url = url + @levelID + '/versions'
+    @url = @url + @levelID + '/versions'
 
 module.exports = class VersionsModal extends ModalView
   template: template
diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee
index 5866e59b7..c758558f2 100644
--- a/app/views/play/CampaignView.coffee
+++ b/app/views/play/CampaignView.coffee
@@ -11,6 +11,7 @@ MusicPlayer = require 'lib/surface/MusicPlayer'
 storage = require 'core/storage'
 AuthModal = require 'views/core/AuthModal'
 SubscribeModal = require 'views/core/SubscribeModal'
+LeaderboardModal = require 'views/play/modal/LeaderboardModal'
 Level = require 'models/Level'
 utils = require 'core/utils'
 require 'vendor/three'
@@ -205,6 +206,7 @@ module.exports = class CampaignView extends RootView
           for nextLevelOriginal in level.nextLevels ? []
             if nextLevel = _.find(@campaign.renderedLevels, original: nextLevelOriginal)
               @createLine level.position, nextLevel.position
+      @showLeaderboard @options.justBeatLevel?.get('slug') if @options.showLeaderboard or true
     @applyCampaignStyles()
     @testParticles()
 
@@ -217,6 +219,11 @@ module.exports = class CampaignView extends RootView
     authModal.mode = 'signup'
     @openModalView authModal
 
+  showLeaderboard: (levelSlug) ->
+    levelSlug ?= 'siege-of-stonehold'  # Testing
+    leaderboardModal = new LeaderboardModal supermodel: @supermodel, levelSlug: levelSlug
+    @openModalView leaderboardModal
+
   determineNextLevel: (levels) ->
     foundNext = false
     for level in levels
diff --git a/app/views/play/level/ControlBarView.coffee b/app/views/play/level/ControlBarView.coffee
index b1a13c1b8..b493fa064 100644
--- a/app/views/play/level/ControlBarView.coffee
+++ b/app/views/play/level/ControlBarView.coffee
@@ -33,6 +33,7 @@ module.exports = class ControlBarView extends CocoView
     @level = options.level
     @levelID = @level.get('slug')
     @spectateGame = options.spectateGame ? false
+    @observing = options.session.get('creator') isnt me.id
     super options
     if @level.get('type') in ['hero-ladder'] and me.isAdmin()
       @isMultiplayerLevel = true
@@ -66,6 +67,7 @@ module.exports = class ControlBarView extends CocoView
       c.difficultyTitle = "#{$.i18n.t 'play.level_difficulty'}#{c.levelDifficulty}"
       @lastDifficulty = c.levelDifficulty
     c.spectateGame = @spectateGame
+    c.observing = @observing
     @homeViewArgs = [{supermodel: if @hasReceivedMemoryWarning then null else @supermodel}]
     if @level.get('type', true) in ['ladder', 'ladder-tutorial', 'hero-ladder']
       levelID = @level.get('slug').replace /\-tutorial$/, ''
diff --git a/app/views/play/level/LevelHUDView.coffee b/app/views/play/level/LevelHUDView.coffee
index 9c70591b1..02c6cb239 100644
--- a/app/views/play/level/LevelHUDView.coffee
+++ b/app/views/play/level/LevelHUDView.coffee
@@ -103,7 +103,7 @@ module.exports = class LevelHUDView extends CocoView
     if @thang.id in ['Hero Placeholder', 'Hero Placeholder 1']
       name = {knight: 'Tharin', captain: 'Anya', librarian: 'Hushbaum', sorcerer: 'Pender', 'potion-master': 'Omarn', samurai: 'Hattori', ninja: 'Amara'}[@thang.type] ? 'Hero'
     else
-      name = if @thang.type then "#{@thang.id} - #{@thang.type}" else @thang.id
+      name = @thang.hudName or (if @thang.type then "#{@thang.id} - #{@thang.type}" else @thang.id)
     utils.replaceText @$el.find('.thang-name'), name
     props = @$el.find('.thang-props')
     props.find('.prop').remove()
diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee
index e445911dc..d2afbc082 100644
--- a/app/views/play/level/PlayLevelView.coffee
+++ b/app/views/play/level/PlayLevelView.coffee
@@ -95,6 +95,7 @@ module.exports = class PlayLevelView extends RootView
 
     @isEditorPreview = @getQueryVariable 'dev'
     @sessionID = @getQueryVariable 'session'
+    @observing = @getQueryVariable 'observing'
 
     @opponentSessionID = @getQueryVariable('opponent')
     @opponentSessionID ?= @options.opponent
@@ -109,7 +110,7 @@ module.exports = class PlayLevelView extends RootView
       setTimeout f, 100
     else
       @load()
-      application.tracker?.trackEvent 'Started Level Load', category: 'Play Level', level: @levelID, label: @levelID, ['Google Analytics']
+      application.tracker?.trackEvent 'Started Level Load', category: 'Play Level', level: @levelID, label: @levelID, ['Google Analytics'] unless @observing
 
   setLevel: (@level, givenSupermodel) ->
     @supermodel.models = givenSupermodel.models
@@ -134,8 +135,9 @@ module.exports = class PlayLevelView extends RootView
     @loadEndTime = new Date()
     loadDuration = @loadEndTime - @loadStartTime
     console.debug "Level unveiled after #{(loadDuration / 1000).toFixed(2)}s"
-    application.tracker?.trackEvent 'Finished Level Load', category: 'Play Level', label: @levelID, level: @levelID, loadDuration: loadDuration, ['Google Analytics']
-    application.tracker?.trackTiming loadDuration, 'Level Load Time', @levelID, @levelID
+    unless @observing
+      application.tracker?.trackEvent 'Finished Level Load', category: 'Play Level', label: @levelID, level: @levelID, loadDuration: loadDuration, ['Google Analytics']
+      application.tracker?.trackTiming loadDuration, 'Level Load Time', @levelID, @levelID
 
   # CocoView overridden methods ###############################################
 
@@ -147,7 +149,7 @@ module.exports = class PlayLevelView extends RootView
   afterRender: ->
     super()
     window.onPlayLevelViewLoaded? @  # still a hack
-    @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil, level: @levelLoader?.level ? @level  # May not have @level loaded yet
+    @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level  # May not have @level loaded yet
     @$el.find('#level-done-button').hide()
     $('body').addClass('is-playing')
     $('body').bind('touchmove', false) if @isIPadApp()
@@ -233,7 +235,7 @@ module.exports = class PlayLevelView extends RootView
     @god.setGoalManager @goalManager
 
   insertSubviews: ->
-    @insertSubView @tome = new TomeView levelID: @levelID, session: @session, otherSession: @otherSession, thangs: @world.thangs, supermodel: @supermodel, level: @level
+    @insertSubView @tome = new TomeView levelID: @levelID, session: @session, otherSession: @otherSession, thangs: @world.thangs, supermodel: @supermodel, level: @level, observing: @observing
     @insertSubView new LevelPlaybackView session: @session, level: @level
     @insertSubView new GoalsView {}
     @insertSubView new LevelFlagsView levelID: @levelID, world: @world if @$el.hasClass 'flags'
@@ -283,7 +285,7 @@ module.exports = class PlayLevelView extends RootView
     return unless @levelLoader.progress() is 1  # double check, since closing the guide may trigger this early
 
     # Save latest level played.
-    if not (@levelLoader.level.get('type') in ['ladder', 'ladder-tutorial'])
+    if not @observing and not (@levelLoader.level.get('type') in ['ladder', 'ladder-tutorial'])
       me.set('lastLevel', @levelID)
       me.save()
       application.tracker?.identify()
@@ -321,7 +323,7 @@ module.exports = class PlayLevelView extends RootView
     if @otherSession and not (@level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'])
       # TODO: colorize name and cloud by team, colorize wizard by user's color config
       @surface.createOpponentWizard id: @otherSession.get('creator'), name: @otherSession.get('creatorName'), team: @otherSession.get('team'), levelSlug: @level.get('slug'), codeLanguage: @otherSession.get('submittedCodeLanguage')
-    if @isEditorPreview
+    if @isEditorPreview or @observing
       @loadingView.startUnveiling()
       @loadingView.unveil()
 
@@ -337,7 +339,7 @@ module.exports = class PlayLevelView extends RootView
       Backbone.Mediator.publish 'playback:real-time-playback-waiting', {}
       @realTimeMultiplayerContinueGame @options.realTimeMultiplayerSessionID
     # TODO: Is it possible to create a Mongoose ObjectId for 'ls', instead of the string returned from get()?
-    application.tracker?.trackEvent 'Started Level', category:'Play Level', levelID: @levelID, ls: @session?.get('_id')
+    application.tracker?.trackEvent 'Started Level', category:'Play Level', levelID: @levelID, ls: @session?.get('_id') unless @observing
 
   playAmbientSound: ->
     return if @destroyed
@@ -414,7 +416,7 @@ module.exports = class PlayLevelView extends RootView
     return if @victorySeen
     @victorySeen = true
     victoryTime = (new Date()) - @loadEndTime
-    if victoryTime > 10 * 1000   # Don't track it if we're reloading an already-beaten level
+    if not @observing and victoryTime > 10 * 1000   # Don't track it if we're reloading an already-beaten level
       application.tracker?.trackEvent 'Saw Victory',
         category: 'Play Level'
         level: @level.get('name')
@@ -436,12 +438,12 @@ module.exports = class PlayLevelView extends RootView
     @tome.reloadAllCode()
     Backbone.Mediator.publish 'level:restarted', {}
     $('#level-done-button', @$el).hide()
-    application.tracker?.trackEvent 'Confirmed Restart', category: 'Play Level', level: @level.get('name'), label: @level.get('name')
+    application.tracker?.trackEvent 'Confirmed Restart', category: 'Play Level', level: @level.get('name'), label: @level.get('name') unless @observing
 
   onInfiniteLoop: (e) ->
     return unless e.firstWorld
     @openModalView new InfiniteLoopModal()
-    application.tracker?.trackEvent 'Saw Initial Infinite Loop', category: 'Play Level', level: @level.get('name'), label: @level.get('name')
+    application.tracker?.trackEvent 'Saw Initial Infinite Loop', category: 'Play Level', level: @level.get('name'), label: @level.get('name') unless @observing
 
   onHighlightDOM: (e) -> @highlightElement e.selector, delay: e.delay, sides: e.sides, offset: e.offset, rotation: e.rotation
 
@@ -525,6 +527,7 @@ module.exports = class PlayLevelView extends RootView
     # TODO: Show a victory dialog specific to hero-ladder level
     if @goalManager.checkOverallStatus() is 'success' and not @options.realTimeMultiplayerSessionID?
       showModalFn = -> Backbone.Mediator.publish 'level:show-victory', showModal: true
+      @session.recordScores @world.scores, @level
       if @level.get 'replayable'
         @session.increaseDifficulty showModalFn
       else
diff --git a/app/views/play/level/modal/HeroVictoryModal.coffee b/app/views/play/level/modal/HeroVictoryModal.coffee
index d88760dae..18d6ecbac 100644
--- a/app/views/play/level/modal/HeroVictoryModal.coffee
+++ b/app/views/play/level/modal/HeroVictoryModal.coffee
@@ -23,6 +23,7 @@ module.exports = class HeroVictoryModal extends ModalView
 
   events:
     'click #continue-button': 'onClickContinue'
+    'click .leaderboard-button': 'onClickLeaderboard'
     'click .return-to-ladder-button': 'onClickReturnToLadder'
     'click .sign-up-button': 'onClickSignupButton'
 
@@ -145,6 +146,12 @@ module.exports = class HeroVictoryModal extends ModalView
       # Show the "I'm done" button between 30 - 120 minutes if they definitely came from Hour of Code
       c.showHourOfCodeDoneButton = me.get('hourOfCode') and showDone
 
+    if @level.get('scoreTypes')?.length
+      lg = me.getLeaderboardsGroup()
+      c.showLeaderboard = lg is 'always'
+      c.showLeaderboard = true if me.level() >= 3 and lg.group is 'early'
+      c.showLeaderboard = true if me.level() >= 5 and lg.group is 'late'
+
     return c
 
   afterRender: ->
@@ -322,11 +329,18 @@ module.exports = class HeroVictoryModal extends ModalView
     link += '/' + nextCampaign unless nextCampaign is 'dungeon'
     link
 
-  onClickContinue: (e) ->
+  onClickContinue: (e, extraOptions=null) ->
     @playSound 'menu-button-click'
     nextLevelLink = @getNextLevelLink()
     # Preserve the supermodel as we navigate back to the world map.
-    Backbone.Mediator.publish 'router:navigate', route: nextLevelLink, viewClass: require('views/play/CampaignView'), viewArgs: [{supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel}, @getNextLevelCampaign()]
+    options =
+      justBeatLevel: @level
+      supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel
+    _.merge options, extraOptions if extraOptions
+    Backbone.Mediator.publish 'router:navigate', route: nextLevelLink, viewClass: require('views/play/CampaignView'), viewArgs: [options, @getNextLevelCampaign()]
+
+  onClickLeaderboard: (e) ->
+    @onClickContinue e, showLeaderboard: true
 
   onClickReturnToLadder: (e) ->
     @playSound 'menu-button-click'
diff --git a/app/views/play/level/tome/CastButtonView.coffee b/app/views/play/level/tome/CastButtonView.coffee
index cc61c1bd1..64b1324a8 100644
--- a/app/views/play/level/tome/CastButtonView.coffee
+++ b/app/views/play/level/tome/CastButtonView.coffee
@@ -27,6 +27,7 @@ module.exports = class CastButtonView extends CocoView
     @spells = options.spells
     @castShortcut = '⇧↵'
     @updateReplayabilityInterval = setInterval @updateReplayability, 1000
+    @observing = options.session.get('creator') isnt me.id
 
   destroy: ->
     clearInterval @updateReplayabilityInterval
@@ -40,6 +41,7 @@ module.exports = class CastButtonView extends CocoView
     castRealTimeShortcutVerbose = (if @isMac() then 'Cmd' else 'Ctrl') + '+' + castShortcutVerbose
     context.castVerbose = castShortcutVerbose + ': ' + $.i18n.t('keyboard_shortcuts.run_code')
     context.castRealTimeVerbose = castRealTimeShortcutVerbose + ': ' + $.i18n.t('keyboard_shortcuts.run_real_time')
+    context.observing = @observing
     context
 
   afterRender: ->
@@ -73,6 +75,7 @@ module.exports = class CastButtonView extends CocoView
     @updateReplayability()
 
   onDoneButtonClick: (e) ->
+    @options.session.recordScores @world.scores, @options.level
     Backbone.Mediator.publish 'level:show-victory', showModal: true
 
   onSpellChanged: (e) ->
@@ -97,6 +100,7 @@ module.exports = class CastButtonView extends CocoView
       @playSound 'cast-end', 0.5
     @hasCastOnce = true
     @updateCastButton()
+    @world = e.world
 
   onNewGoalStates: (e) ->
     winnable = e.overallStatus is 'success'
diff --git a/app/views/play/level/tome/Spell.coffee b/app/views/play/level/tome/Spell.coffee
index 33ee28de2..9c4c98f6f 100644
--- a/app/views/play/level/tome/Spell.coffee
+++ b/app/views/play/level/tome/Spell.coffee
@@ -15,6 +15,7 @@ module.exports = class Spell
     @otherSession = options.otherSession
     @spectateView = options.spectateView
     @spectateOpponentCodeLanguage = options.spectateOpponentCodeLanguage
+    @observing = options.observing
     @supermodel = options.supermodel
     @skipProtectAPI = options.skipProtectAPI
     @worker = options.worker
@@ -189,6 +190,7 @@ module.exports = class Spell
     return true if @spectateView  # Use transpiled code for both teams if we're just spectating.
     return true if @isEnemySpell()  # Use transpiled for enemy spells.
     # Players without permissions can't view the raw code.
+    return false if @observing and @levelType is 'hero'
     return true if @session.get('creator') isnt me.id and not (me.isAdmin() or 'employer' in me.get('permissions', true))
     false
 
diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee
index 7f0e3465d..4f45d87d5 100644
--- a/app/views/play/level/tome/SpellView.coffee
+++ b/app/views/play/level/tome/SpellView.coffee
@@ -69,6 +69,7 @@ module.exports = class SpellView extends CocoView
     @writable = false unless me.team in @spell.permissions.readwrite  # TODO: make this do anything
     @highlightCurrentLine = _.throttle @highlightCurrentLine, 100
     $(window).on 'resize', @onWindowResize
+    @observing = @session.get('creator') isnt me.id
 
   afterRender: ->
     super()
@@ -119,14 +120,15 @@ module.exports = class SpellView extends CocoView
       name: 'run-code'
       bindKey: {win: 'Shift-Enter|Ctrl-Enter', mac: 'Shift-Enter|Command-Enter|Ctrl-Enter'}
       exec: -> Backbone.Mediator.publish 'tome:manual-cast', {}
-    addCommand
-      name: 'run-code-real-time'
-      bindKey: {win: 'Ctrl-Shift-Enter', mac: 'Command-Shift-Enter|Ctrl-Shift-Enter'}
-      exec: =>
-        if @options.level.get('replayable') and (timeUntilResubmit = @session.timeUntilResubmit()) > 0
-          Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit
-        else
-          Backbone.Mediator.publish 'tome:manual-cast', {realTime: true}
+    unless @observing
+      addCommand
+        name: 'run-code-real-time'
+        bindKey: {win: 'Ctrl-Shift-Enter', mac: 'Command-Shift-Enter|Ctrl-Shift-Enter'}
+        exec: =>
+          if @options.level.get('replayable') and (timeUntilResubmit = @session.timeUntilResubmit()) > 0
+            Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit
+          else
+            Backbone.Mediator.publish 'tome:manual-cast', {realTime: true}
     addCommand
       name: 'no-op'
       bindKey: {win: 'Ctrl-S', mac: 'Command-S|Ctrl-S'}
diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee
index 3ba0c88b0..c31886023 100644
--- a/app/views/play/level/tome/TomeView.coffee
+++ b/app/views/play/level/tome/TomeView.coffee
@@ -134,6 +134,7 @@ module.exports = class TomeView extends CocoView
             language: language
             spectateView: @options.spectateView
             spectateOpponentCodeLanguage: @options.spectateOpponentCodeLanguage
+            observing: @options.observing
             levelID: @options.levelID
             level: @options.level
 
diff --git a/app/views/play/modal/LeaderboardModal.coffee b/app/views/play/modal/LeaderboardModal.coffee
new file mode 100644
index 000000000..a9ba8fc65
--- /dev/null
+++ b/app/views/play/modal/LeaderboardModal.coffee
@@ -0,0 +1,56 @@
+ModalView = require 'views/core/ModalView'
+template = require 'templates/play/modal/leaderboard-modal'
+LeaderboardTabView = require 'views/play/modal/LeaderboardTabView'
+Level = require 'models/Level'
+
+module.exports = class LeaderboardModal extends ModalView
+  id: 'leaderboard-modal'
+  template: template
+  instant: true
+  timespans: ['day', 'week', 'all']
+
+  subscriptions: {}
+
+  events:
+    'shown.bs.tab #leaderboard-nav a': 'onTabShown'
+    'click #close-modal': 'hide'
+
+  constructor: (options) ->
+    super options
+    @levelSlug = @options.levelSlug
+    @level = @supermodel.loadModel(new Level({_id: @levelSlug}, {project: ['name', 'i18n', 'scoreType', 'original']}), 'level').model
+
+  getRenderData: (c) ->
+    c = super c
+    c.submenus = []
+    for scoreType in @level.get('scoreTypes') ? []
+      for timespan in @timespans
+        c.submenus.push scoreType: scoreType, timespan: timespan
+    c
+
+  afterRender: ->
+    super()
+    return unless @supermodel.finished()
+    for scoreType, scoreTypeIndex in @level.get('scoreTypes') ? []
+      for timespan, timespanIndex in @timespans
+        submenuView = new LeaderboardTabView scoreType: scoreType, timespan: timespan, level: @level
+        @insertSubView submenuView, @$el.find "##{scoreType}-#{timespan}-view .leaderboard-tab-view"
+        if scoreTypeIndex + timespanIndex is 0
+          submenuView.$el.parent().addClass 'active'
+          submenuView.onShown?()
+    @playSound 'game-menu-open'
+    @$el.find('.nano:visible').nanoScroller()
+
+  onTabShown: (e) ->
+    @playSound 'game-menu-tab-switch'
+    tabChunks = e.target.hash.substring(1).split '-'
+    scoreType = tabChunks[0 ... tabChunks.length - 2].join '-'
+    timespan = tabChunks[tabChunks.length - 2]
+    subview = _.find @subviews, scoreType: scoreType, timespan: timespan
+    subview.onShown?()
+    otherSubview.onHidden?() for subviewKey, otherSubview of @subviews when otherSubview isnt subview
+
+  onHidden: ->
+    super()
+    subview.onHidden?() for subviewKey, subview of @subviews
+    @playSound 'game-menu-close'
diff --git a/app/views/play/modal/LeaderboardTabView.coffee b/app/views/play/modal/LeaderboardTabView.coffee
new file mode 100644
index 000000000..33faa4680
--- /dev/null
+++ b/app/views/play/modal/LeaderboardTabView.coffee
@@ -0,0 +1,75 @@
+CocoView = require 'views/core/CocoView'
+template = require 'templates/play/modal/leaderboard-tab-view'
+CocoCollection = require 'collections/CocoCollection'
+LevelSession = require 'models/LevelSession'
+
+class TopScoresCollection extends CocoCollection
+  url: ''
+  model: LevelSession
+
+  constructor: (@level, @scoreType, @timespan) ->
+    super()
+    @url = "/db/level/#{@level.get('original')}/top_scores/#{@scoreType}/#{@timespan}"
+
+module.exports = class LeaderboardTabView extends CocoView
+  template: template
+  className: 'leaderboard-tab-view'
+
+  events:
+    'click tbody tr.viewable': 'onClickRow'
+
+  constructor: (options) ->
+    super options
+    @level = @options.level
+    @scoreType = @options.scoreType ? 'time'
+    @timespan = @options.timespan
+
+  destroy: ->
+    super()
+
+  getRenderData: ->
+    c = super()
+    c.scoreType = @scoreType
+    c.timespan = @timespan
+    c.topScores = @formatTopScores()
+    c.loading = not @sessions or @sessions.loading
+    c._ = _
+    c
+
+  afterRender: ->
+    super()
+
+  formatTopScores: ->
+    return [] unless @sessions?.models
+    rows = []
+    for s in @sessions.models
+      row = {}
+      score = _.find s.get('state').topScores, type: @scoreType
+      row.ago = moment(new Date(score.date)).fromNow()
+      row.score = @formatScore score
+      row.creatorName = s.get 'creatorName'
+      row.creator = s.get 'creator'
+      row.session = s.id
+      row.codeLanguage = s.get 'codeLanguage'
+      row.hero = s.get('heroConfig')?.thangType
+      row.inventory = s.get('heroConfig')?.inventory
+      rows.push row
+    rows
+
+  formatScore: (score) ->
+    switch score.type
+      when 'time' then -score.score.toFixed(2) + 's'
+      when 'damage-taken' then -Math.round score.score
+      when 'damage-dealt', 'gold-collected', 'difficulty' then Math.round score.score
+      else score.score
+
+  onShown: ->
+    return if @hasShown
+    @hasShown = true
+    topScores = new TopScoresCollection @level, @scoreType, @timespan
+    @sessions = @supermodel.loadCollection(topScores, 'sessions', null, 0).model
+
+  onClickRow: (e) ->
+    sessionID = $(e.target).closest('tr').data 'session-id'
+    url = "/play/level/#{@level.get('slug')}?session=#{sessionID}&observing=true"
+    window.open url, '_blank'
diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee
index aa6adeacb..921598d3d 100644
--- a/server/levels/level_handler.coffee
+++ b/server/levels/level_handler.coffee
@@ -59,6 +59,7 @@ LevelHandler = class LevelHandler extends Handler
     'campaign'
     'replayable'
     'buildTime'
+    'scoreTypes'
   ]
 
   postEditableProperties: ['name']
@@ -77,6 +78,7 @@ LevelHandler = class LevelHandler extends Handler
     return @checkExistence(req, res, args[0]) if args[1] is 'exists'
     return @getPlayCountsBySlugs(req, res) if args[1] is 'play_counts'
     return @getLevelPlaytimesBySlugs(req, res) if args[1] is 'playtime_averages'
+    return @getTopScores(req, res, args[0], args[2], args[3]) if args[1] is 'top_scores'
     super(arguments...)
 
   fetchLevelByIDAndHandleErrors: (id, req, res, callback) ->
@@ -400,4 +402,33 @@ LevelHandler = class LevelHandler extends Handler
       @levelPlaytimesCache[cacheKey] = playtimes
       @sendSuccess res, playtimes
 
+  getTopScores: (req, res, levelOriginal, scoreType, timespan) ->
+    query =
+      'level.original': levelOriginal
+      'state.topScores.type': scoreType
+    now = new Date()
+    if timespan is 'day'
+      since = new Date now - 1 * 86400 * 1000
+    else if timespan is 'week'
+      since = new Date now - 7 * 86400 * 1000
+    if since
+      query['state.topScores.date'] = $gt: since.toISOString()
+
+    sort =
+      'state.topScores.score': -1
+
+    select = ['state.topScores', 'creatorName', 'creator', 'codeLanguage', 'heroConfig']
+
+    query = Session
+      .find(query)
+      .limit(20)
+      .sort(sort)
+      .select(select.join ' ')
+
+    query.exec (err, resultSessions) =>
+      return @sendDatabaseError(res, err) if err
+      resultSessions ?= []
+      @sendSuccess res, resultSessions
+
+
 module.exports = new LevelHandler()
diff --git a/server/levels/sessions/LevelSession.coffee b/server/levels/sessions/LevelSession.coffee
index 8285e6790..49153fde7 100644
--- a/server/levels/sessions/LevelSession.coffee
+++ b/server/levels/sessions/LevelSession.coffee
@@ -1,5 +1,3 @@
-# TODO: not updated since rename from level_instance, or since we redid how all models are done; probably busted
-
 mongoose = require 'mongoose'
 plugins = require '../../plugins/plugins'
 AchievablePlugin = require '../../plugins/achievements'
@@ -24,6 +22,7 @@ LevelSessionSchema.index({submitted: 1}, {sparse: true})
 LevelSessionSchema.index({team: 1}, {sparse: true})
 LevelSessionSchema.index({totalScore: 1}, {sparse: true})
 LevelSessionSchema.index({user: 1, changed: -1}, {name: 'last played index', sparse: true})
+LevelSessionSchema.index({'level.original': 1, 'state.topScores.type': 1, 'state.topScores.date': -1, 'state.topScores.score': -1}, {name: 'top scores index', sparse: true})
 
 LevelSessionSchema.plugin(plugins.PermissionsPlugin)
 LevelSessionSchema.plugin(AchievablePlugin)
@@ -65,7 +64,8 @@ LevelSessionSchema.statics.privateProperties = ['code', 'submittedCode', 'unsubs
 LevelSessionSchema.statics.editableProperties = ['multiplayer', 'players', 'code', 'codeLanguage', 'completed', 'state',
                                                  'levelName', 'creatorName', 'levelID', 'screenshot',
                                                  'chat', 'teamSpells', 'submitted', 'submittedCodeLanguage',
-                                                 'unsubscribed', 'playtime', 'heroConfig', 'team', 'transpiledCode']
+                                                 'unsubscribed', 'playtime', 'heroConfig', 'team', 'transpiledCode',
+                                                 'browser']
 LevelSessionSchema.statics.jsonSchema = jsonschema
 
 module.exports = LevelSession = mongoose.model('level.session', LevelSessionSchema, 'level.sessions')
diff --git a/server/levels/sessions/level_session_handler.coffee b/server/levels/sessions/level_session_handler.coffee
index 171f90fc8..208b68071 100644
--- a/server/levels/sessions/level_session_handler.coffee
+++ b/server/levels/sessions/level_session_handler.coffee
@@ -15,7 +15,10 @@ class LevelSessionHandler extends Handler
 
   formatEntity: (req, document) ->
     documentObject = super(req, document)
-    if req.user?.isAdmin() or req.user?.id is document.creator or ('employer' in (req.user?.get('permissions') ? []))
+    if req.user?.isAdmin() or
+       req.user?.id is document.creator or
+       ('employer' in (req.user?.get('permissions') ? [])) or
+       !document.submittedCode  # TODO: only allow leaderboard access to non-top-5 solutions
       return documentObject
     else
       return _.omit documentObject, @privateProperties
@@ -47,8 +50,9 @@ class LevelSessionHandler extends Handler
       @sendSuccess res, documents
 
   hasAccessToDocument: (req, document, method=null) ->
-    return true if req.method is 'GET' and document.get('submitted')
-    return true if ('employer' in (req.user?.get('permissions') ? [])) and (method ? req.method).toLowerCase() is 'get'
+    get = (method ? req.method).toLowerCase() is 'get'
+    return true if get and document.get('submitted')
+    return true if get and ('employer' in (req.user?.get('permissions') ? []))
     super(arguments...)
 
   getCodeLanguageCounts: (req, res) ->