diff --git a/app/core/Router.coffee b/app/core/Router.coffee
index 13caacf6c..7cffebca8 100644
--- a/app/core/Router.coffee
+++ b/app/core/Router.coffee
@@ -66,6 +66,7 @@ module.exports = class CocoRouter extends Backbone.Router
     'editor/level/:levelID': go('editor/level/LevelEditView')
     'editor/thang': go('editor/thang/ThangTypeSearchView')
     'editor/thang/:thangID': go('editor/thang/ThangTypeEditView')
+    'editor/campaign/:campaignID': go('editor/campaign/CampaignEditorView')
 
     'employers': go('EmployersView')
 
@@ -84,7 +85,7 @@ module.exports = class CocoRouter extends Backbone.Router
     'multiplayer': go('MultiplayerView')
 
     'play-old': go('play/MainPlayView')  # This used to be 'play'.
-    'play': go('play/WorldMapView')
+    'play': go('play/CampaignView')
     'play/ladder/:levelID': go('ladder/LadderView')
     'play/ladder': go('ladder/MainLadderView')
     'play/level/:levelID': go('play/level/PlayLevelView')
diff --git a/app/core/treema-ext.coffee b/app/core/treema-ext.coffee
index 22bdfc6ac..cb41d61fd 100644
--- a/app/core/treema-ext.coffee
+++ b/app/core/treema-ext.coffee
@@ -313,7 +313,7 @@ class InternationalizationNode extends TreemaNode.nodeMap.object
 
 class LatestVersionCollection extends CocoCollection
 
-class LatestVersionReferenceNode extends TreemaNode
+module.exports.LatestVersionReferenceNode = class LatestVersionReferenceNode extends TreemaNode
   searchValueTemplate: '<input placeholder="Search" /><div class="treema-search-results"></div>'
   valueClass: 'treema-latest-version'
   url: '/db/article'
@@ -383,7 +383,11 @@ class LatestVersionReferenceNode extends TreemaNode
     m = CocoModel.getReferencedModel(@getData(), @workingSchema)
     data = @getData()
     if _.isString data  # LatestVersionOriginalReferenceNode just uses original
-      m = @settings.supermodel.getModelByOriginal(m.constructor, data)
+      if m.schema().properties.version
+        m = @settings.supermodel.getModelByOriginal(m.constructor, data)
+      else
+        # get by id
+        m = @settings.supermodel.getModel(m.constructor, data)
     else
       m = @settings.supermodel.getModelByOriginalAndMajorVersion(m.constructor, data.original, data.majorVersion)
     if @instance and not m
@@ -434,7 +438,7 @@ class LatestVersionReferenceNode extends TreemaNode
     selected = @getSelectedResultEl()
     return not selected.length
 
-class LatestVersionOriginalReferenceNode extends LatestVersionReferenceNode
+module.exports.LatestVersionOriginalReferenceNode = class LatestVersionOriginalReferenceNode extends LatestVersionReferenceNode
   # Just for saving the original, not the major version.
   saveChanges: ->
     selected = @getSelectedResultEl()
@@ -443,6 +447,15 @@ class LatestVersionOriginalReferenceNode extends LatestVersionReferenceNode
     @data = fullValue.attributes.original
     @instance = fullValue
 
+module.exports.IDReferenceNode = class IDReferenceNode extends LatestVersionReferenceNode
+  # Just for saving the _id
+  saveChanges: ->
+    selected = @getSelectedResultEl()
+    return unless selected.length
+    fullValue = selected.data('value')
+    @data = fullValue.attributes._id
+    @instance = fullValue
+
 class LevelComponentReferenceNode extends LatestVersionReferenceNode
   # HACK: this list of properties is needed by the thang components edit view and config views.
   # need a better way to specify this, or keep the search models from bleeding into those
diff --git a/app/models/Campaign.coffee b/app/models/Campaign.coffee
new file mode 100644
index 000000000..fec933a31
--- /dev/null
+++ b/app/models/Campaign.coffee
@@ -0,0 +1,10 @@
+CocoModel = require './CocoModel'
+schema = require 'schemas/models/campaign.schema'
+
+module.exports = class Campaign extends CocoModel
+  @className: 'Campaign'
+  @schema: schema
+  urlRoot: '/db/campaign'
+  saveBackups: true
+  @denormalizedLevelProperties: _.keys(_.omit(schema.properties.levels.additionalProperties.properties, ['unlocks', 'position', 'rewards']))
+  @denormalizedCampaignProperties: ['name', 'i18n', 'description', 'slug']
diff --git a/app/schemas/models/campaign.schema.coffee b/app/schemas/models/campaign.schema.coffee
new file mode 100644
index 000000000..77eeddb42
--- /dev/null
+++ b/app/schemas/models/campaign.schema.coffee
@@ -0,0 +1,121 @@
+c = require './../schemas'
+
+CampaignSchema = c.object()
+c.extendNamedProperties CampaignSchema  # name first
+
+_.extend CampaignSchema.properties, {
+  i18n: {type: 'object', title: 'i18n', format: 'i18n', props: ['name', 'body']}
+  
+  ambientSound: c.object {},
+    mp3: { type: 'string', format: 'sound-file' }
+    ogg: { type: 'string', format: 'sound-file' }
+    
+  backgroundImage: c.array {}, {
+    type: 'object'
+    additionalProperties: false
+    properties: {
+      image: { type: 'string', format: 'image-file' }
+      width: { type: 'number' }
+    }
+  }
+  backgroundColor: { type: 'string' }
+  backgroundColorTransparent: { type: 'string' }
+  
+  adjacentCampaigns: { type: 'object', format: 'campaigns', additionalProperties: {
+    title: 'Campaign'
+    type: 'object'
+    format: 'campaign'
+    properties: {
+      #- denormalized from other Campaigns, either updated automatically or fetched dynamically
+      id: { type: 'string', format: 'hidden' }
+      name: { type: 'string', format: 'hidden' }
+      description: { type: 'string', format: 'hidden' }
+      i18n: { type: 'object', format: 'hidden' }
+      slug: { type: 'string', format: 'hidden' }
+      
+      #- normal properties
+      position: c.point2d()
+      rotation: { type: 'number', format: 'degrees' }
+      color: { type: 'string' }
+      showIfUnlocked: { type: 'string', links: [{rel: 'db', href: '/db/level/{($)}/version'}], format: 'latest-version-original-reference' }
+    }
+  }}
+  
+  levels: { type: 'object', format: 'levels', additionalProperties: {
+    title: 'Level'
+    type: 'object'
+    format: 'level'
+    additionalProperties: false
+    
+    # key is the original property
+    properties: {
+      #- denormalized from Level
+      name: { type: 'string', format: 'hidden' }
+      description: { type: 'string', format: 'hidden' }
+      requiresSubscription: { type: 'boolean' }
+      type: {'enum': ['campaign', 'ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop']}
+      slug: { type: 'string', format: 'hidden' }
+      original: { type: 'string', format: 'hidden' }
+      adventurer: { type: 'boolean' }
+      practice: { type: 'boolean' }
+      disableSpaces: { type: 'boolean' }
+      hidesSubmitUntilRun: { type: 'boolean' }
+      hidesPlayButton: { type: 'boolean' }
+      hidesRunShortcut: { type: 'boolean' }
+      hidesHUD: { type: 'boolean' }
+      hidesSay: { type: 'boolean' }
+      hidesCodeToolbar: { type: 'boolean' }
+      hidesRealTimePlayback: { type: 'boolean' }
+      backspaceThrottle: { type: 'boolean' }
+      lockDefaultCode: { type: 'boolean' }
+      moveRightLoopSnippet: { type: 'boolean' }
+      realTimeSpeedFactor: { type: 'number' }
+      autocompleteFontSizePx: { type: 'number' }
+
+      requiredCode: c.array {}, { 
+        type: 'string' 
+      }
+      suspectCode: c.array {}, {
+        type: 'object'
+        properties: {
+          name: { type: 'string' }
+          pattern: { type: 'string' }
+        }
+      }
+      
+      requiredGear: { type: 'object', additionalProperties: {
+        type: 'array'
+        items: { type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' }
+      }}
+      restrictedGear: { type: 'object', additionalProperties: {
+        type: 'array' 
+        items: { type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' }
+      }}
+      allowedHeroes: { type: 'array', items: { 
+        type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' 
+      }}
+
+      #- denormalized from Achievements
+      rewards: { type: 'array', items: {
+        type: 'object'
+        additionalProperties: false
+        properties:
+          achievement: { type: 'string', links: [{rel: 'db', href: '/db/achievement/{{$}}'}], format: 'achievement' }
+          item: { type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' }
+          hero: { type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' }
+          level: { type: 'string', links: [{rel: 'db', href: '/db/level/{($)}/version'}], format: 'latest-version-original-reference' }
+          type: { enum: ['heroes', 'items', 'levels'] }
+      }}
+
+      #- normal properties
+      position: c.point2d()
+    }
+
+  }}
+}
+
+
+c.extendBasicProperties CampaignSchema, 'campaign'
+c.extendTranslationCoverageProperties CampaignSchema
+
+module.exports = CampaignSchema
diff --git a/app/schemas/models/level.coffee b/app/schemas/models/level.coffee
index 93d6b513e..78e81acf5 100644
--- a/app/schemas/models/level.coffee
+++ b/app/schemas/models/level.coffee
@@ -296,6 +296,42 @@ _.extend LevelSchema.properties,
   requiresSubscription: {title: 'Requires Subscription', description: 'Whether this level is available to subscribers only.', type: 'boolean'}
   tasks: c.array {title: 'Tasks', description: 'Tasks to be completed for this level.', default: (name: t for t in defaultTasks)}, c.task
 
+  # Admin flags
+  adventurer: { type: 'boolean' }
+  practice: { type: 'boolean' }
+  disableSpaces: { type: 'boolean' }
+  hidesSubmitUntilRun: { type: 'boolean' }
+  hidesPlayButton: { type: 'boolean' }
+  hidesRunShortcut: { type: 'boolean' }
+  hidesHUD: { type: 'boolean' }
+  hidesSay: { type: 'boolean' }
+  hidesCodeToolbar: { type: 'boolean' }
+  hidesRealTimePlayback: { type: 'boolean' }
+  backspaceThrottle: { type: 'boolean' }
+  lockDefaultCode: { type: 'boolean' }
+  moveRightLoopSnippet: { type: 'boolean' }
+  realTimeSpeedFactor: { type: 'number' }
+  autocompleteFontSizePx: { type: 'number' }
+  requiredCode: c.array {}, {
+    type: 'string'
+  }
+  suspectCode: c.array {}, {
+    type: 'object'
+    properties: {
+      name: { type: 'string' }
+      pattern: { type: 'string' }
+    }
+  }
+  requiredGear: { type: 'object', additionalProperties: {
+    type: 'string'
+  }}
+  restrictedGear: { type: 'object', additionalProperties: {
+    type: 'string'
+  }}
+  allowedHeroes: { type: 'array', items: {
+    type: 'string'
+  }}
+  
 c.extendBasicProperties LevelSchema, 'level'
 c.extendSearchableProperties LevelSchema
 c.extendVersionedProperties LevelSchema, 'level'
diff --git a/app/styles/editor/campaign/campaign-editor-view.sass b/app/styles/editor/campaign/campaign-editor-view.sass
new file mode 100644
index 000000000..cdee91359
--- /dev/null
+++ b/app/styles/editor/campaign/campaign-editor-view.sass
@@ -0,0 +1,28 @@
+#campaign-editor-view
+  #left-column
+    position: absolute
+    top: 0
+    bottom: 0
+    left: 0
+    width: 25%
+    margin-right: 1200px
+    
+    .treema-root
+      max-height: 100%
+      overflow: scroll
+    
+  #right-column
+    position: absolute
+    top: 0
+    bottom: 0
+    right: 0
+    width: 75%
+
+  #campaign-level-view
+    position: absolute
+    top: 0
+    left: 0
+    right: 0
+    bottom: 0
+    background-color: white
+    z-index: 3
\ No newline at end of file
diff --git a/app/styles/play/campaign-view.sass b/app/styles/play/campaign-view.sass
new file mode 100644
index 000000000..802206b8a
--- /dev/null
+++ b/app/styles/play/campaign-view.sass
@@ -0,0 +1,495 @@
+@import "app/styles/mixins"
+@import "app/styles/bootstrap/variables"
+
+$mapHeight: 1536
+$forestMapWidth: 2500
+$dungeonMapWidth: 2350
+$desertMapWidth: 2350
+$desertMapSeaBackground: rgba(113, 186, 208, 1)
+$desertMapSeaBackgroundTransparent: rgba(113, 186, 208, 0)
+$forestMapSeaBackground: rgba(113, 186, 208, 1)
+$forestMapSeaBackgroundTransparent: rgba(113, 186, 208, 0)
+$dungeonMapCaveBackground: rgba(68, 54, 45, 1)
+$dungeonMapCaveBackgroundTransparent: rgba(68, 54, 45, 0)
+$levelDotWidth: 2%
+$levelDotHeight: $levelDotWidth * $forestMapWidth / $mapHeight
+$levelDotZ: $levelDotHeight * 0.25
+$levelDotHoverZ: $levelDotZ * 2
+$levelDotShadowWidth: 0.8 * $levelDotWidth
+$levelDotShadowHeight: 0.8 * $levelDotHeight
+$levelClickRadius: 40px
+$gameControlSize: 80px
+$gameControlMargin: 30px
+
++keyframes(levelStartedPulse)
+  from
+    @include box-shadow(0px 0px 4px #333)
+    margin-bottom: -$levelDotHeight / 3 + $levelDotZ
+  50%
+    @include box-shadow(0px 0px 22px skyblue)
+    margin-bottom: -$levelDotHeight / 3 + ($levelDotHoverZ + $levelDotZ) / 2
+  to
+    @include box-shadow(0px 0px 4px #333)
+    margin-bottom: -$levelDotHeight / 3 + $levelDotZ
+
+#campaign-view
+  width: 100%
+  height: 100%
+  position: absolute
+
+  .gradient
+    position: absolute
+    z-index: 0
+
+    &.horizontal-gradient
+      left: 0
+      right: 0
+      height: 3%
+
+    &.vertical-gradient
+      top: 0
+      bottom: 0
+      width: 3%
+
+    &.top-gradient
+      top: 0
+
+    &.right-gradient
+      right: 0
+
+    &.bottom-gradient
+      bottom: 0
+
+    &.left-gradient
+      left: 0
+
+  &.desert
+    background-color: $desertMapSeaBackground
+
+    .top-gradient
+      background: linear-gradient(to bottom, $desertMapSeaBackground 0%, $desertMapSeaBackgroundTransparent 100%)
+
+    .right-gradient
+      background: linear-gradient(to left, $desertMapSeaBackground 0%, $desertMapSeaBackgroundTransparent 100%)
+
+    .bottom-gradient
+      background: linear-gradient(to top, $desertMapSeaBackground 0%, $desertMapSeaBackgroundTransparent 100%)
+
+    .left-gradient
+      background: linear-gradient(to right, $desertMapSeaBackground 0%, $desertMapSeaBackgroundTransparent 100%)
+
+  &.forest
+    background-color: $forestMapSeaBackground
+
+    .top-gradient
+      background: linear-gradient(to bottom, $forestMapSeaBackground 0%, $forestMapSeaBackgroundTransparent 100%)
+
+    .right-gradient
+      background: linear-gradient(to left, $forestMapSeaBackground 0%, $forestMapSeaBackgroundTransparent 100%)
+
+    .bottom-gradient
+      background: linear-gradient(to top, $forestMapSeaBackground 0%, $forestMapSeaBackgroundTransparent 100%)
+
+    .left-gradient
+      background: linear-gradient(to right, $forestMapSeaBackground 0%, $forestMapSeaBackgroundTransparent 100%)
+
+  &.dungeon
+    background-color: $dungeonMapCaveBackground
+
+    .top-gradient
+      background: linear-gradient(to bottom, $dungeonMapCaveBackground 0%, $dungeonMapCaveBackgroundTransparent 100%)
+
+    .right-gradient
+      background: linear-gradient(to left, $dungeonMapCaveBackground 0%, $dungeonMapCaveBackgroundTransparent 100%)
+
+    .bottom-gradient
+      background: linear-gradient(to top, $dungeonMapCaveBackground 0%, $dungeonMapCaveBackgroundTransparent 100%)
+
+    .left-gradient
+      background: linear-gradient(to right, $dungeonMapCaveBackground 0%, $dungeonMapCaveBackgroundTransparent 100%)
+
+  .map
+    position: relative
+
+    .map-background
+      width: 100%
+      height: 100%
+      background-size: 100%
+      @include user-select(none)
+      
+      &.map-dungeon
+        background-image: url('/images/pages/play/map_dungeon_1920.jpg')
+        @media screen and ( max-width: 1366px )
+          background-image: url('/images/pages/play/map_dungeon_1366.jpg')
+
+      &.map-forest
+        background-image: url('/images/pages/play/map_forest_1920.jpg')
+        @media screen and ( max-width: 1366px )
+          background-image: url('/images/pages/play/map_forest_1366.jpg')
+
+      &.map-desert
+        background-image: url('/images/pages/play/map_desert_1920.jpg')
+        @media screen and ( max-width: 1366px )
+          background-image: url('/images/pages/play/map_desert_1366.jpg')
+
+    .level, .level-shadow
+      position: absolute
+      border-radius: 100%
+      -webkit-transform: scaleY(0.75)
+      transform: scaleY(0.75)
+
+    .level
+      z-index: 2
+      width: $levelDotWidth
+      height: $levelDotHeight
+      margin-left: -0.5 * $levelDotWidth
+      margin-bottom: -$levelDotHeight / 3 + $levelDotZ
+      border: 2px groove white
+      @include transition(margin-bottom 0.5s ease)
+
+      &.disabled, &.locked
+        background-image: url(/images/pages/game-menu/lock.png)
+        background-size: 75%
+        background-repeat: no-repeat
+        background-position: 50% 50%
+        opacity: 0.7
+
+        a
+          cursor: default
+
+      &.next
+        width: 2 * $levelDotWidth
+        height: 2 * $levelDotHeight
+        margin-left: -0.5 * 2 * $levelDotWidth
+        margin-bottom: -2 * $levelDotHeight / 3 + 2 * $levelDotZ
+
+      &.started, &.next
+        border: 3px solid lightgreen
+        @include box-shadow(0px 0px 35px skyblue)
+
+        // Would be cool, but kills performance, since we have to re-render all the time.
+        //&:not(:hover)
+        //  -webkit-animation-name: levelStartedPulse
+        //  -webkit-animation-duration: 3s
+        //  -webkit-animation-iteration-count: infinite
+
+      &.complete
+        border: 3px solid gold
+        @include box-shadow(0px 0px 35px skyblue)
+        
+      img.banner
+        position: absolute
+        bottom: 38%
+        left: -50%
+        width: 200%
+        pointer-events: none
+        
+      img.star
+        width: 100%
+        bottom: 7%
+        position: absolute
+        pointer-events: none
+        
+      .glyphicon-star
+        position: absolute
+        color: lightblue
+        font-size: 21px
+        left: 1.5px
+        
+      &.started .glyphicon-star
+        left: 0.5px
+        
+      img.hero-portrait
+        width: 120%
+        position: absolute
+        bottom: 75%
+        left: 75%
+        border: 1px solid black
+        border-radius: 100%
+        background: white
+        
+
+    .level-shadow
+      z-index: 1
+      width: $levelDotShadowWidth
+      height: $levelDotShadowHeight
+      margin-left: -0.5 * $levelDotShadowWidth
+      margin-bottom: -$levelDotShadowHeight / 3
+      background-color: black
+      @include box-shadow(0px 0px 10px black)
+      @include opacity(0.75)
+
+      &.next
+        width: 2 * $levelDotShadowWidth
+        height: 2 * $levelDotShadowHeight
+        margin-left: -0.5 * 2 * $levelDotShadowWidth
+        margin-bottom: -2 * $levelDotShadowHeight / 3
+
+    .level:hover
+      // TODO: This rotate stops Firefox from flickering, but also disables the scaleY(0.75)
+      // TODO: The dot looks like it's jumping.
+      // TODO: -moz-transform: scaleY(0.75) didn't do anything
+      // TODO: Does not break Chrome's oval.
+      -moz-transform: rotate(0)
+      margin-bottom: -$levelDotHeight / 3 + $levelDotHoverZ
+      @include box-shadow(0px 0px 35px skyblue)
+
+      &.next
+        margin-bottom: -2 * $levelDotHeight / 3 + 2 * $levelDotHoverZ
+
+    .level
+      a
+        display: block
+        padding: $levelClickRadius
+        margin-left: -0.5 * $levelClickRadius
+        margin-top: -0.5 * $levelClickRadius
+        border-radius: $levelClickRadius
+
+      &.next a
+        padding: 2 * $levelClickRadius
+        margin-left: 2 * -0.5 * $levelClickRadius
+        margin-top: 2 * -0.5 * $levelClickRadius
+        border-radius: 2 * $levelClickRadius
+
+    .tooltip
+      z-index: 2
+
+    .level-info-container
+      display: none
+      position: absolute
+      z-index: 3
+      padding: 10px
+      border-width: 16px 12px
+      // Using modernizr-mixin for compat detection
+      @include yep(borderimage)
+        border-style: solid
+        border-image: url(/images/level/popover_border_background.png) 16 12 fill round
+      @include nope(borderimage)
+        background-color: rgb(247, 242, 218)
+
+      .level-info.complete h3:after
+        content: " - Complete!"
+        color: green
+    
+      .level-info.started h3:after
+        content: " - Started"
+        color: desaturate(green, 50%)
+    
+      .level-info
+        h3
+          margin-top: 0
+          margin-bottom: 0px
+
+        .level-description
+          color: black
+          text-shadow: 0 1px 0 white
+
+        .campaign-label
+          text-shadow: 0 1px 0 white
+
+      .start-level
+        display: block
+        margin: 10px auto 0 auto
+        width: 200px
+      
+    .campaign-switch
+      color: purple
+      position: absolute
+      z-index: 1
+      font-size: 2vw
+      text-shadow: 0 0 0.3vw white, 0 0 0.3vw white
+      
+      &:hover
+        text-decoration: none
+      
+      &#desert-link
+        left: 90%
+        top: 18.5%
+        transform: scaleY(-1.5) scaleX(1.5)
+      
+      &#forest-back-link
+        left: 2%
+        top: 70.5%
+        transform: rotate(216deg)
+      
+      &#forest-link
+        left: 94.5%
+        top: 7%
+        transform: rotate(-35deg)
+      
+      &#dungeon-link
+        left: 9%
+        top: 54.5%
+        transform: rotate(180deg)
+        color: fuchsia
+          
+  .game-controls
+    position: absolute
+    right: 1%
+    bottom: 1%
+    z-index: 3
+
+    .btn
+      &:not(:first-child)
+        margin-left: $gameControlMargin
+      width: $gameControlSize
+      height: $gameControlSize
+      
+      background: url(/images/pages/play/menu_icons.png) no-repeat
+      
+      position: relative
+      img
+        position: absolute
+        left: 0
+        top: 0
+        width: 100%
+        height: 100%
+        
+      background-size: cover
+      @include transition(0.5s ease)
+      @include box-shadow(2px 2px 4px black)
+      border: 0
+      border-radius: 12px
+      // IE9 shows a blank white button with this MS gradient filter in place
+      filter: none 
+      
+      &:hover
+        @include box-shadow(0 0 12px #bbf)
+
+      &:active
+        @include box-shadow(0 0 20px white)
+      
+      &.heroes
+        background-position: (-1 * $gameControlSize) 0px
+      &.achievements
+        background-position: (-2 * $gameControlSize) 0px
+      &.account
+        background-position: (-3 * $gameControlSize) 0px
+      &.settings
+        background-position: (-4 * $gameControlSize) 0px
+      &.gems
+        background-position: (-5 * $gameControlSize) 0px
+
+    .tooltip
+      font-size: 24px
+
+      .tooltip-arrow
+        display: none
+
+  .user-status
+    position: absolute
+    bottom: 16px
+    left: 8px
+    text-align: center
+    font-size: 24px
+    color: white
+    text-shadow: 0px 2px 1px black, 0px -2px 1px black, -2px 0px 1px black, 2px 0px 1px black
+    height: 32px
+    line-height: 32px
+
+    .user-status-line
+      position: relative
+  
+      button.btn.btn-illustrated
+        margin-left: 10px
+        min-width: 90px
+        height: 32px
+        color: white
+
+      .gem, .player-level-icon, .player-hero-icon
+        position: absolute
+        top: 1px
+
+      #gems-count
+        margin-left: 40px
+
+      .player-level
+        margin-left: 34px
+
+      .player-name
+        margin-left: 45px
+
+      $spriteSheetSize: 30px
+  
+      .player-level-icon, .player-hero-icon
+        background: transparent url(/images/pages/play/play-spritesheet.png)
+        background-size: cover
+        background-position: (-2 * $spriteSheetSize) 0
+        display: inline-block
+        width: 30px
+        height: 30px
+        margin: 0px 2px
+
+      .player-hero-icon
+        margin-left: 10px
+        background-position: (-4 * $spriteSheetSize) 0
+
+        &.knight
+          background-position: (-5 * $spriteSheetSize) 0
+        &.librarian
+          background-position: (-6 * $spriteSheetSize) 0
+        &.ninja
+          background-position: (-7 * $spriteSheetSize) 0
+        &.potion-master
+          background-position: (-8 * $spriteSheetSize) 0
+        &.samurai
+          background-position: (-9 * $spriteSheetSize) 0
+        &.trapper
+          background-position: (-10 * $spriteSheetSize) 0
+        &.forest-archer
+          background-position: (-11 * $spriteSheetSize) 0
+        &.sorcerer
+          background-position: (-12 * $spriteSheetSize) 0
+      
+
+  #volume-button
+    position: absolute
+    left: 1%
+    top: 1%
+    padding: 3px 8px
+    @include opacity(0.75)
+
+    &:hover
+      @include opacity(1.0)
+
+    .glyphicon
+      display: none
+      font-size: 32px
+
+    &.vol-up .glyphicon.glyphicon-volume-up
+      display: inline-block
+
+    &.vol-off .glyphicon.glyphicon-volume-off
+      display: inline-block
+      @include opacity(0.50)
+      &:hover
+        @include opacity(0.75)
+
+    &.vol-down .glyphicon.glyphicon-volume-down
+      display: inline-block
+
+  #campaign-status
+    position: absolute
+    left: 0
+    top: 15px
+    width: 100%
+    margin: 0
+    text-align: center
+    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
+
+
+body:not(.ipad) #campaign-view
+  .level-info-container
+    pointer-events: none
+
+  
+
+body.ipad #campaign-view
+  // iPad only supports up to Kithgard Gates for now.
+  .campaign-switch
+    display: none
+
+  .old-levels
+    display: none
diff --git a/app/templates/editor/campaign/campaign-editor-view.jade b/app/templates/editor/campaign/campaign-editor-view.jade
new file mode 100644
index 000000000..2515ed484
--- /dev/null
+++ b/app/templates/editor/campaign/campaign-editor-view.jade
@@ -0,0 +1,44 @@
+extends /templates/base
+
+block header
+  if campaign.loading
+    nav.navbar.navbar-default(role='navigation')#campaign-editor-top-nav
+      .container-fluid
+        ul.nav.navbar-nav
+          li
+            a(href="/")
+              span.glyphicon-home.glyphicon
+  else
+    nav.navbar.navbar-default(role='navigation')#campaign-editor-top-nav
+      ul.nav.navbar-nav
+        li
+          a(href="/")
+            span.glyphicon-home.glyphicon
+
+      ul.nav.navbar-nav.navbar-right
+        if me.isAdmin()
+          li#save-button
+            a
+              span.glyphicon-floppy-disk.glyphicon
+        li.dropdown
+          a(data-toggle='dropdown')
+            span.glyphicon-chevron-down.glyphicon
+          ul.dropdown-menu
+            li.dropdown-header Actions
+            li(class=anonymous ? "disabled": "")
+              a(data-toggle="coco-modal", data-target="modal/RevertModal", data-i18n="editor.revert")#revert-button Revert
+            li(class=anonymous ? "disabled": "")
+              a(data-i18n="editor.pop_i18n")#pop-level-i18n-button Populate i18n
+            li.divider
+            li.dropdown-header Info
+
+block outer_content
+  .outer-content
+    #left-column
+      #campaign-treema
+
+    #right-column
+      #campaign-view
+      #campaign-level-view.hidden
+
+block footer
diff --git a/app/templates/editor/campaign/campaign-level-view.jade b/app/templates/editor/campaign/campaign-level-view.jade
new file mode 100644
index 000000000..fd2723d52
--- /dev/null
+++ b/app/templates/editor/campaign/campaign-level-view.jade
@@ -0,0 +1,4 @@
+.jumbotron
+  .button.close(type="button", aria-hidden="true") &times;
+  h1= level.get('name')
+  p= level.get('description')
diff --git a/app/templates/editor/campaign/save-campaign-modal.jade b/app/templates/editor/campaign/save-campaign-modal.jade
new file mode 100644
index 000000000..40633074b
--- /dev/null
+++ b/app/templates/editor/campaign/save-campaign-modal.jade
@@ -0,0 +1,21 @@
+extends /templates/core/modal-base
+
+block modal-header-content
+  h3 Save Changes to Campaign
+
+block modal-body-content
+  if !modelsToSave.models.length
+    .alert.alert-info No changes
+  
+  for model in modelsToSave.models
+    .panel.panel-default
+      .panel-heading
+        span.panel-title.spr= model.get('name')
+        span.text-muted= model.constructor.className 
+      .panel-body
+        .delta-view(data-model-id=model.id)
+
+block modal-footer
+  .modal-footer
+    button(data-dismiss="modal", data-i18n="common.cancel").btn Cancel
+    button.btn.btn-primary#save-button Save
diff --git a/app/templates/play/campaign-view.jade b/app/templates/play/campaign-view.jade
new file mode 100644
index 000000000..6bcb6ad80
--- /dev/null
+++ b/app/templates/play/campaign-view.jade
@@ -0,0 +1,99 @@
+.map
+  .gradient.horizontal-gradient.top-gradient
+  .gradient.vertical-gradient.right-gradient
+  .gradient.horizontal-gradient.bottom-gradient
+  .gradient.vertical-gradient.left-gradient
+  .map-background(class="map-"+mapType alt="", draggable="false")
+
+  each level in levels
+    if !level.hidden
+      - var next = nextLevel && level.slug === nextLevel;
+      div(style="left: #{level.position.x}%; bottom: #{level.position.y}%; background-color: #{level.color}", class="level" + (next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + levelStatusMap[level.original] || "", data-level-id=level.original, title=level.name + (level.disabled ? ' (Coming Soon to Adventurers)' : ''))
+        if level.unlocksHero && !level.unlockedHero
+          img.hero-portrait(src=level.unlocksHero.img)
+        a(href=level.type == 'hero' ? '#' : level.disabled ? "/play" : "/play/#{level.levelPath || 'level'}/#{level.original}", disabled=level.disabled, data-level-id=level.original, data-level-path=level.levelPath || 'level', data-level-name=level.name)
+        if level.requiresSubscription
+          img.star(src="/images/pages/play/star.png")
+        if levelStatusMap[level.original] === 'complete'
+          img.banner(src="/images/pages/play/level-banner-complete.png")
+        if levelStatusMap[level.original] === 'started'
+          img.banner(src="/images/pages/play/level-banner-started.png")
+      div(style="left: #{level.position.x}%; bottom: #{level.position.y}%", class="level-shadow" + (next ? " next" : "") + " " + levelStatusMap[level.original] || "")
+      .level-info-container(data-level-id=level.original, data-level-path=level.levelPath || 'level', data-level-name=level.name)
+        div(class="level-info " + (levelStatusMap[level.original] || ""))
+          h3= level.name + (level.disabled ? " (Coming soon!)" : (level.locked ? " (Locked)" : ""))
+          .level-description= level.description
+          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.
+              
+          - var playCount = levelPlayCountMap[level.original]
+          if playCount && playCount.sessions > 20
+            div
+              span.spr #{playCount.sessions}
+              span(data-i18n="play.players") players
+              span.spr , #{Math.round(playCount.playtime / 3600)}
+              span(data-i18n="play.hours_played") hours played
+          .campaign-label= campaign.get('name')
+        if isIPadApp && !level.disabled && !level.locked
+          button.btn.btn-success.btn-lg.start-level(data-i18n="common.play") Play
+  
+  for adjacentCampaign in adjacentCampaigns
+    a
+      span.glyphicon.glyphicon-share-alt.campaign-switch(href="/play/"+adjacentCampaign.slug, style=adjacentCampaign.style, title=adjacentCampaign.name, data-campaign-id=adjacentCampaign.id)    
+
+.game-controls.header-font
+  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.isAdmin()
+    button.btn.account(data-toggle='coco-modal', data-target='play/modal/PlayAccountModal', data-i18n="[title]play.account")
+    button.btn.settings(data-toggle='coco-modal', data-target='play/modal/PlaySettingsModal', data-i18n="[title]play.settings")
+  else if me.get('anonymous', true)
+    button.btn.settings(data-toggle='coco-modal', data-target='core/AuthModal', data-i18n="[title]play.settings")
+  // Don't show these things, they are bad and take us out of the game. Just wait until the new ones work.
+  //else
+  //  a.btn.achievements(href="/user/#{me.getSlugOrID()}/stats", data-i18n="[title]play.achievements")
+  //  a.btn.account(href="/user/#{me.getSlugOrID()}", data-i18n="[title]play.account")
+  //  a.btn.settings(href='/account', data-i18n="[title]play.settings")
+  
+.user-status.header-font
+  .user-status-line
+    span.gem.gem-30
+    span#gems-count.spr= me.gems()
+    span.player-level-icon
+    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
+      span.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#volume-button(title="Adjust volume")
+  .glyphicon.glyphicon-volume-off
+  .glyphicon.glyphicon-volume-down
+  .glyphicon.glyphicon-volume-up
+
+//h1#campaign-status
+//  if mapType == 'dungeon'
+//    span.spr(data-i18n="play.campaign_dungeon")
+//  else if mapType == 'forest'
+//    span.spr(data-i18n="play.campaign_forest")
+//  | -
+//  if requiresSubscription
+//    span.spl(data-i18n="play.subscription_required")
+//  else if mapType == 'dungeon'
+//    span.spl(data-i18n="play.free")
+//  else
+//    span.spl(data-i18n="play.subscribed")
\ No newline at end of file
diff --git a/app/templates/play/world-map-view.jade b/app/templates/play/world-map-view.jade
index ccc189642..2a88a63d9 100644
--- a/app/templates/play/world-map-view.jade
+++ b/app/templates/play/world-map-view.jade
@@ -8,7 +8,7 @@
   - var seenNext = nextLevel;
   each level in campaign.levels
     if !level.hidden
-      - var next = level.id == nextLevel || (!seenNext && levelStatusMap[level.id] != "complete" && !level.locked && !level.disabled);
+      - var next = level.id == nextLevel || (!seenNext && levelStatusMap[level.id] != "complete" && !level.locked && !level.disabled && !editorMode);
       - seenNext = seenNext || next;
       div(style="left: #{level.x}%; bottom: #{level.y}%; background-color: #{level.color}", class="level" + (next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + levelStatusMap[level.id] || "", data-level-id=level.id, title=level.name + (level.disabled ? ' (Coming Soon to Adventurers)' : ''))
         if level.unlocksHero && !level.unlockedHero
diff --git a/app/views/editor/campaign/CampaignEditorView.coffee b/app/views/editor/campaign/CampaignEditorView.coffee
new file mode 100644
index 000000000..58976b6ec
--- /dev/null
+++ b/app/views/editor/campaign/CampaignEditorView.coffee
@@ -0,0 +1,944 @@
+RootView = require 'views/core/RootView'
+Campaign = require 'models/Campaign'
+Level = require 'models/Level'
+Achievement = require 'models/Achievement'
+ThangType = require 'models/ThangType'
+CampaignView = require 'views/play/CampaignView'
+CocoCollection = require 'collections/CocoCollection'
+treemaExt = require 'core/treema-ext'
+utils = require 'core/utils'
+SaveCampaignModal = require './SaveCampaignModal'
+RelatedAchievementsCollection = require 'collections/RelatedAchievementsCollection'
+CampaignLevelView = require './CampaignLevelView'
+
+achievementProject = ['related', 'rewards', 'name', 'slug']
+thangTypeProject = ['name', 'original', 'slug']
+
+module.exports = class CampaignEditorView extends RootView
+  id: "campaign-editor-view"
+  template: require 'templates/editor/campaign/campaign-editor-view'
+  className: 'editor'
+  
+  events:
+    'click #save-button': 'onClickSaveButton'
+
+  constructor: (options, @campaignHandle) ->
+    super(options)
+    
+    # MIGRATION CODE
+#    for level in levels
+#      _.extend level, options[level.id]
+#      level.slug = level.id
+#      delete level.id
+#      delete level.nextLevels
+#      level.position = { x: level.x, y: level.y }
+#      delete level.x
+#      delete level.y
+#      if level.unlocksHero
+#        level.unlocks = [{
+#          original: level.unlocksHero.originalID
+#          type: 'hero'
+#        }]
+#      delete level.unlocksHero
+#      campaign.levels[level.original] = level
+#    @campaign = new Campaign(campaign)
+    #------------------------------------------------
+  
+    @campaign = new Campaign({_id:@campaignHandle})
+    
+    #--------------- temporary migration to change thang type slugs to originals
+    #- should keep around though for loading the names of items and heroes that are referenced
+    #- just load names instead of slugs, though
+    @sluggyThangs = new Backbone.Collection()
+    @listenToOnce @campaign, 'sync', ->
+      slugs = []
+      for level in _.values(@campaign.get('levels'))
+        slugs = slugs.concat(_.values(level.requiredGear)) if level.requiredGear
+        slugs = slugs.concat(_.values(level.restrictedGear)) if level.restrictedGear 
+        slugs = slugs.concat(level.allowedHeroes) if level.allowedHeroes
+      slugs = _.uniq _.flatten slugs
+      for slug in slugs
+        thangType = new ThangType()
+        thangType.setProjection(thangTypeProject)
+        if utils.isID slug
+          thangType.setURL("/db/thang.type/#{slug}/version")
+        else
+          thangType.setURL("/db/thang.type/#{slug}")
+        @supermodel.loadModel(thangType, 'thang')
+        @sluggyThangs.add(thangType)
+    #---------------
+    @supermodel.loadModel(@campaign, 'campaign')
+
+    @levels = new CocoCollection([], {
+      model: Level
+      url: "/db/campaign/#{@campaignHandle}/levels"
+      project: Campaign.denormalizedLevelProperties
+    })
+    @supermodel.loadCollection(@levels, 'levels')
+
+    @achievements = new CocoCollection([], {
+      model: Achievement
+      url: "/db/campaign/#{@campaignHandle}/achievements"
+      project: achievementProject
+    })
+    @supermodel.loadCollection(@achievements, 'achievements')
+    
+    @toSave = new Backbone.Collection()
+    @listenToOnce @campaign, 'sync', @onFundamentalLoaded
+    @listenToOnce @levels, 'sync', @onFundamentalLoaded
+    @listenToOnce @achievements, 'sync', @onFundamentalLoaded
+
+  onFundamentalLoaded: ->
+    # load any levels which 
+    return unless @campaign.loaded and @levels.loaded and @achievements.loaded
+    for level in _.values(@campaign.get('levels'))
+      model = @levels.findWhere(original: level.original)
+      if not model
+        model = new Level({})
+        model.setProjection Campaign.denormalizedLevelProperties
+        model.setURL("/db/level/#{level.original}/version")
+        @levels.add @supermodel.loadModel(model, 'level').model
+        achievements = new RelatedAchievementsCollection level.original
+        achievements.setProjection achievementProject
+        @supermodel.loadCollection achievements, 'achievements'
+        @listenToOnce achievements, 'sync', ->
+          @achievements.add(achievements.models)
+      
+
+  onLoaded: ->
+    @toSave.add @campaign if @campaign.hasLocalChanges()
+    campaignLevels = $.extend({}, @campaign.get('levels'))
+    for level in @levels.models
+      levelOriginal = level.get('original')
+      campaignLevel = campaignLevels[levelOriginal]
+      continue if not campaignLevel
+
+      #--------------- temporary migrations
+      if campaignLevel.restrictedGear
+        for slot, value of campaignLevel.restrictedGear
+          if _.isString(value)
+            campaignLevel.restrictedGear[slot] = [value]
+      #
+      if campaignLevel.requiredGear
+        for slot, value of campaignLevel.requiredGear
+          if _.isString(value)
+            campaignLevel.requiredGear[slot] = [value]
+      #
+      if campaignLevel.requiredGear
+        for gear in _.values(campaignLevel.requiredGear)
+          for slug, index in gear
+            thang = @sluggyThangs.findWhere({slug: slug})
+            continue unless thang
+            gear[index] = thang.get('original')
+      #
+      if campaignLevel.restrictedGear
+        for gear in _.values(campaignLevel.restrictedGear)
+          for slug, index in gear
+            thang = @sluggyThangs.findWhere({slug: slug})
+            continue unless thang
+            gear[index] = thang.get('original')
+      #
+      if campaignLevel.allowedHeroes
+        for slug, index in campaignLevel.allowedHeroes
+          thang = @sluggyThangs.findWhere({slug: slug})
+          continue unless thang
+          level.allowedHeroes[index] = thang.get('original')
+      #---------------
+        
+      $.extend campaignLevel, _.omit(level.attributes, '_id')
+      achievements = @achievements.where {'related': levelOriginal}
+      rewards = []
+      for achievement in achievements
+        for rewardType, rewardArray of achievement.get('rewards')
+          for reward in rewardArray
+            rewardObject = { achievement: achievement.id }
+            
+            if rewardType is 'heroes'
+              rewardObject.hero = reward
+              thangType = new ThangType({}, {project: thangTypeProject})
+              thangType.setURL("/db/thang.type/#{reward}/version")
+              @supermodel.loadModel(thangType, 'thang')
+                
+            if rewardType is 'levels'
+              rewardObject.level = reward
+              if not @levels.findWhere({original: reward})
+                level = new Level({}, {project: Campaign.denormalizedLevelProperties})
+                level.setURL("/db/level/#{reward}/version")
+                @supermodel.loadModel(level, 'level')
+                
+            if rewardType is 'items'
+              rewardObject.item = reward
+              thangType = new ThangType({}, {project: thangTypeProject})
+              thangType.setURL("/db/thang.type/#{reward}/version")
+              @supermodel.loadModel(thangType, 'thang')
+              
+            rewards.push rewardObject
+      campaignLevel.rewards = rewards
+      delete campaignLevel.unlocks
+      campaignLevels[levelOriginal] = campaignLevel
+      
+    @campaign.set('levels', campaignLevels)
+    
+    for level in _.values campaignLevels
+      model = @levels.findWhere {original: level.original}
+      model.set key, level[key] for key in Campaign.denormalizedLevelProperties
+#      @toSave.add model if model.hasLocalChanges()
+#      @updateRewardsForLevel model, level.rewards
+
+    super()
+
+  getRenderData: ->
+    c = super()
+    c.campaign = @campaign
+    c
+
+  onClickSaveButton: ->
+    @toSave.set @toSave.filter (m) -> m.hasLocalChanges()
+    @openModalView new SaveCampaignModal({}, @toSave)
+    
+  afterRender: ->
+    super()
+    treemaOptions =
+      schema: Campaign.schema
+      data: $.extend({}, @campaign.attributes)
+      callbacks:
+        change: @onTreemaChanged
+        select: @onTreemaSelectionChanged
+        dblclick: @onTreemaDoubleClicked
+      nodeClasses:
+        levels: LevelsNode
+        level: LevelNode
+        campaigns: CampaignsNode
+        campaign: CampaignNode
+        achievement: AchievementNode
+      supermodel: @supermodel
+
+    @treema = @$el.find('#campaign-treema').treema treemaOptions
+    @treema.build()
+    @treema.open()
+    @treema.childrenTreemas.levels?.open()
+
+    @campaignView = new CampaignView({editorMode: true, supermodel: @supermodel}, @campaignHandle)
+    @campaignView.highlightElement = _.noop # make it stop
+    @listenTo @campaignView, 'level-moved', @onCampaignLevelMoved
+    @listenTo @campaignView, 'adjacent-campaign-moved', @onAdjacentCampaignMoved
+    @listenTo @campaignView, 'level-clicked', @onCampaignLevelClicked
+    @insertSubView @campaignView
+    
+  onTreemaChanged: (e, nodes) =>
+    for node in nodes
+      path = node.getPath()
+      if _.string.startsWith path, '/levels/'
+        parts = path.split('/')
+        original = parts[2]
+        level = @supermodel.getModelByOriginal Level, original
+        campaignLevel = @treema.get "/levels/#{original}"
+        
+        @updateRewardsForLevel level, campaignLevel.rewards
+        
+        level.set key, campaignLevel[key] for key in Campaign.denormalizedLevelProperties
+        @toSave.add level if level.hasLocalChanges()
+        
+    @toSave.add @campaign
+    @campaign.set key, value for key, value of @treema.data
+    @campaignView.setCampaign(@campaign)
+
+  onTreemaDoubleClicked: (e, node) =>
+    path = node.getPath()
+    return unless _.string.startsWith path, '/levels/'
+    original = path.split('/')[2]
+    level = @supermodel.getModelByOriginal Level, original
+    @insertSubView new CampaignLevelView({}, level)
+
+  onCampaignLevelMoved: (e) ->
+    path = "levels/#{e.levelOriginal}/position"
+    @treema.set path, e.position
+
+  onAdjacentCampaignMoved: (e) ->
+    path = "adjacentCampaigns/#{e.campaignID}/position"
+    @treema.set path, e.position
+
+  onCampaignLevelClicked: (levelOriginal) ->
+    return unless levelTreema = @treema.childrenTreemas?.levels?.childrenTreemas?[levelOriginal]
+    levelTreema.select()
+#    levelTreema.open()
+
+  updateRewardsForLevel: (level, rewards) ->
+    achievements = @supermodel.getModels(Achievement)
+    achievements = (a for a in achievements when a.get('related') is level.get('original'))
+    for achievement in achievements
+      rewardSubset = (r for r in rewards when r.achievement is achievement.id)
+      oldRewards = achievement.get 'rewards'
+      newRewards = {}
+      
+      heroes = _.compact((r.hero for r in rewardSubset))
+      newRewards.heroes = heroes if heroes.length
+      
+      items = _.compact((r.item for r in rewardSubset))
+      newRewards.items = items if items.length
+
+      levels = _.compact((r.level for r in rewardSubset))
+      newRewards.levels = levels if levels.length
+      
+      newRewards.gems = oldRewards.gems if oldRewards.gems
+      achievement.set 'rewards', newRewards
+      if achievement.hasLocalChanges()
+        @toSave.add achievement
+
+class LevelsNode extends TreemaObjectNode
+  valueClass: 'treema-levels'
+  @levels: {}
+  
+  buildValueForDisplay: (valEl, data) ->
+    @buildValueForDisplaySimply valEl, ''+_.size(data)
+
+  childPropertiesAvailable: -> @childSource
+
+  childSource: (req, res) =>
+    s = new Backbone.Collection([], {model:Level})
+    s.url = '/db/level'
+    s.fetch({data: {term:req.term, project: Campaign.denormalizedLevelProperties.join(',')}})
+    s.once 'sync', (collection) =>
+      for level in collection.models
+        LevelsNode.levels[level.get('original')] = level
+        @settings.supermodel.registerModel level
+      mapped = ({label: r.get('name'), value: r.get('original')} for r in collection.models)
+      res(mapped)
+
+
+class LevelNode extends TreemaObjectNode
+  valueClass: 'treema-level'
+  buildValueForDisplay: (valEl, data) ->
+    @buildValueForDisplaySimply valEl, data.name
+    
+  populateData: ->
+    return if @data.name?
+    data = _.pick LevelsNode.levels[@keyForParent].attributes, Campaign.denormalizedLevelProperties
+    _.extend @data, data
+
+class CampaignsNode extends TreemaObjectNode
+  valueClass: 'treema-campaigns'
+  @campaigns: {}
+
+  buildValueForDisplay: (valEl, data) ->
+    @buildValueForDisplaySimply valEl, ''+_.size(data)
+
+  childPropertiesAvailable: -> @childSource
+
+  childSource: (req, res) =>
+    s = new Backbone.Collection([], {model:Campaign})
+    s.url = '/db/campaign'
+    s.fetch({data: {term:req.term, project: campaign.denormalizedCampaignProperties}})
+    s.once 'sync', (collection) ->
+      CampaignsNode.campaigns[campaign.id] = campaign for campaign in collection.models
+      mapped = ({label: r.get('name'), value: r.id} for r in collection.models)
+      res(mapped)
+
+
+class CampaignNode extends TreemaObjectNode
+  valueClass: 'treema-campaign'
+  buildValueForDisplay: (valEl, data) ->
+    @buildValueForDisplaySimply valEl, data.name
+
+  populateData: ->
+    return if @data.name?
+    data = _.pick CampaignsNode.campaigns[@keyForParent].attributes, Campaign.denormalizedCampaignProperties
+    _.extend @data, data
+    
+class AchievementNode extends treemaExt.IDReferenceNode
+  buildSearchURL: (term) -> "#{@url}?term=#{term}&project=#{achievementProject.join(',')}"
+
+
+
+    
+#campaign = {
+#  name: 'Dungeon'
+#  levels: {}
+#}
+#
+#
+#levels = [
+#  {
+#    name: 'Dungeons of Kithgard'
+#    type: 'hero'
+#    id: 'dungeons-of-kithgard'
+#    original: '5411cb3769152f1707be029c'
+#    description: 'Grab the gem, but touch nothing else. Start here.'
+#    x: 14
+#    y: 15.5
+#    nextLevels:
+#      continue: 'gems-in-the-deep'
+#  }
+#  {
+#    name: 'Gems in the Deep'
+#    type: 'hero'
+#    id: 'gems-in-the-deep'
+#    original: '54173c90844506ae0195a0b4'
+#    description: 'Quickly collect the gems; you will need them.'
+#    x: 29
+#    y: 12
+#    nextLevels:
+#      continue: 'shadow-guard'
+#  }
+#  {
+#    name: 'Shadow Guard'
+#    type: 'hero'
+#    id: 'shadow-guard'
+#    original: '54174347844506ae0195a0b8'
+#    description: 'Evade the Kithgard minion.'
+#    x: 40.54
+#    y: 11.03
+#    nextLevels:
+#      continue: 'forgetful-gemsmith'
+#  }
+#  {
+#    name: 'Kounter Kithwise'
+#    type: 'hero'
+#    id: 'kounter-kithwise'
+#    original: '54527a6257e83800009730c7'
+#    description: 'Practice your evasion skills with more guards.'
+#    x: 35.37
+#    y: 20.61
+#    nextLevels:
+#      continue: 'crawlways-of-kithgard'
+#    practice: true
+#    requiresSubscription: true
+#  }
+#  {
+#    name: 'Crawlways of Kithgard'
+#    type: 'hero'
+#    id: 'crawlways-of-kithgard'
+#    original: '545287ef57e83800009730d5'
+#    description: 'Dart in and grab the gem–at the right moment.'
+#    x: 36.48
+#    y: 29.03
+#    nextLevels:
+#      continue: 'forgetful-gemsmith'
+#    practice: true
+#    requiresSubscription: true
+#  }
+#  {
+#    name: 'Forgetful Gemsmith'
+#    type: 'hero'
+#    id: 'forgetful-gemsmith'
+#    original: '544a98f62d002f0000fe331a'
+#    description: 'Grab even more gems as you practice moving.'
+#    x: 54.98
+#    y: 10.53
+#    nextLevels:
+#      continue: 'true-names'
+#  }
+#  {
+#    name: 'True Names'
+#    type: 'hero'
+#    id: 'true-names'
+#    original: '541875da4c16460000ab990f'
+#    description: 'Learn an enemy\'s true name to defeat it.'
+#    x: 68.44
+#    y: 10.70
+#    nextLevels:
+#      continue: 'the-raised-sword'
+#    unlocksHero: {
+#      img: '/file/db/thang.type/53e12be0d042f23505c3023b/portrait.png'
+#      originalID: '53e12be0d042f23505c3023b'
+#    }
+#  }
+#  {
+#    name: 'Favorable Odds'
+#    type: 'hero'
+#    id: 'favorable-odds'
+#    original: '5452972f57e83800009730de'
+#    description: 'Test out your battle skills by defeating more munchkins.'
+#    x: 88.25
+#    y: 14.92
+#    nextLevels:
+#      continue: 'the-raised-sword'
+#    practice: true
+#    requiresSubscription: true
+#  }
+#  {
+#    name: 'The Raised Sword'
+#    type: 'hero'
+#    id: 'the-raised-sword'
+#    original: '5418aec24c16460000ab9aa6'
+#    description: 'Learn to equip yourself for combat.'
+#    x: 81.51
+#    y: 17.92
+#    nextLevels:
+#      continue: 'haunted-kithmaze'
+#  }
+#  {
+#    name: 'Haunted Kithmaze'
+#    type: 'hero'
+#    id: 'haunted-kithmaze'
+#    original: '545a5914d820eb0000f6dc0a'
+#    description: 'The builders of Kithgard constructed many mazes to confuse travelers.'
+#    x: 78
+#    y: 29
+#    nextLevels:
+#      continue: 'the-second-kithmaze'
+#  }
+#  {
+#    name: 'Riddling Kithmaze'
+#    type: 'hero'
+#    id: 'riddling-kithmaze'
+#    original: '5418b9d64c16460000ab9ab4'
+#    description: 'If at first you go astray, change your loop to find the way.'
+#    x: 69.97
+#    y: 28.03
+#    nextLevels:
+#      continue: 'descending-further'
+#    practice: true
+#    requiresSubscription: true
+#  }
+#  {
+#    name: 'Descending Further'
+#    type: 'hero'
+#    id: 'descending-further'
+#    original: '5452a84d57e83800009730e4'
+#    description: 'Another day, another maze.'
+#    x: 61.68
+#    y: 22.80
+#    nextLevels:
+#      continue: 'the-second-kithmaze'
+#    practice: true
+#    requiresSubscription: true
+#  }
+#  {
+#    name: 'The Second Kithmaze'
+#    type: 'hero'
+#    id: 'the-second-kithmaze'
+#    original: '5418cf256bae62f707c7e1c3'
+#    description: 'Many have tried, few have found their way through this maze.'
+#    x: 54.49
+#    y: 26.49
+#    nextLevels:
+#      continue: 'dread-door'
+#  }
+#  {
+#    name: 'Dread Door'
+#    type: 'hero'
+#    id: 'dread-door'
+#    original: '5418d40f4c16460000ab9ac2'
+#    description: 'Behind a dread door lies a chest full of riches.'
+#    x: 60.52
+#    y: 33.70
+#    nextLevels:
+#      continue: 'known-enemy'
+#  }
+#  {
+#    name: 'Known Enemy'
+#    type: 'hero'
+#    id: 'known-enemy'
+#    original: '5452adea57e83800009730ee'
+#    description: 'Begin to use variables in your battles.'
+#    x: 67
+#    y: 39
+#    nextLevels:
+#      continue: 'master-of-names'
+#  }
+#  {
+#    name: 'Master of Names'
+#    type: 'hero'
+#    id: 'master-of-names'
+#    original: '5452c3ce57e83800009730f7'
+#    description: 'Use your glasses to defend yourself from the Kithmen.'
+#    x: 75
+#    y: 46
+#    nextLevels:
+#      continue: 'lowly-kithmen'
+#  }
+#  {
+#    name: 'Lowly Kithmen'
+#    type: 'hero'
+#    id: 'lowly-kithmen'
+#    original: '541b24511ccc8eaae19f3c1f'
+#    description: 'Now that you can see them, they\'re everywhere!'
+#    x: 85
+#    y: 40
+#    nextLevels:
+#      continue: 'closing-the-distance'
+#  }
+#  {
+#    name: 'Closing the Distance'
+#    type: 'hero'
+#    id: 'closing-the-distance'
+#    original: '541b288e1ccc8eaae19f3c25'
+#    description: 'Kithmen are not the only ones to stand in your way.'
+#    x: 93
+#    y: 47
+#    nextLevels:
+#      continue: 'the-final-kithmaze'
+#  }
+#  {
+#    name: 'Tactical Strike'
+#    type: 'hero'
+#    id: 'tactical-strike'
+#    original: '5452cfa706a59e000067e4f5'
+#    description: 'They\'re, uh, coming right for us! Sneak up behind them.'
+#    x: 83.23
+#    y: 52.73
+#    nextLevels:
+#      continue: 'the-final-kithmaze'
+#    practice: true
+#    requiresSubscription: true
+#  }
+#  {
+#    name: 'The Final Kithmaze'
+#    type: 'hero'
+#    id: 'the-final-kithmaze'
+#    original: '541b434e1ccc8eaae19f3c33'
+#    description: 'To escape you must find your way through an Elder Kithman\'s maze.'
+#    x: 86.95
+#    y: 64.70
+#    nextLevels:
+#      continue: 'kithgard-gates'
+#  }
+#  {
+#    name: 'The Gauntlet'
+#    type: 'hero'
+#    id: 'the-gauntlet'
+#    original: '5452d8b906a59e000067e4fa'
+#    description: 'Rush for the stairs, battling foes at every turn.'
+#    x: 76.50
+#    y: 72.69
+#    nextLevels:
+#      continue: 'kithgard-gates'
+#    practice: true
+#    requiresSubscription: true
+#  }
+#  {
+#    name: 'Kithgard Gates'
+#    type: 'hero'
+#    id: 'kithgard-gates'
+#    original: '541c9a30c6362edfb0f34479'
+#    description: 'Escape the Kithgard dungeons and don\'t let the guardians get you.'
+#    x: 89
+#    y: 82
+#    nextLevels:
+#      continue: 'defense-of-plainswood'
+#  }
+#  {
+#    name: 'Cavern Survival'
+#    type: 'hero-ladder'
+#    id: 'cavern-survival'
+#    original: '544437e0645c0c0000c3291d'
+#    description: 'Stay alive longer than your opponent amidst hordes of ogres!'
+#    x: 17.54
+#    y: 78.39
+#  }
+#]
+#
+#options =
+#  'dungeons-of-kithgard':
+#    disableSpaces: true
+#    hidesSubmitUntilRun: true
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots'}
+#    restrictedGear: {feet: 'leather-boots'}
+#    requiredCode: ['moveRight']
+#  'gems-in-the-deep':
+#    disableSpaces: true
+#    hidesSubmitUntilRun: true
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots'}
+#    restrictedGear: {feet: 'leather-boots'}
+#  'shadow-guard':
+#    disableSpaces: true
+#    hidesSubmitUntilRun: true
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots'}
+#    restrictedGear: {feet: 'leather-boots', 'right-hand': 'simple-sword'}
+#  'kounter-kithwise':
+#    disableSpaces: true
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots'}
+#    restrictedGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'}
+#  'crawlways-of-kithgard':
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots'}
+#    restrictedGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'}
+#  'forgetful-gemsmith':
+#    disableSpaces: true
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots'}
+#    restrictedGear: {feet: 'leather-boots', 'programming-book': 'programmaticon-i'}
+#  'true-names':
+#    disableSpaces: true
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', waist: 'leather-belt'}
+#    restrictedGear: {feet: 'leather-boots', 'programming-book': 'programmaticon-i'}
+#    requiredCode: ['Brak']
+#  'favorable-odds':
+#    disableSpaces: true
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword'}
+#    restrictedGear: {feet: 'leather-boots', 'programming-book': 'programmaticon-i'}
+#  'the-raised-sword':
+#    disableSpaces: true
+#    hidesPlayButton: true
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate'}
+#    restrictedGear: {feet: 'leather-boots', 'programming-book': 'programmaticon-i'}
+#  'the-first-kithmaze':
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'programming-book': 'programmaticon-i'}
+#    restrictedGear: {feet: 'leather-boots'}
+#    requiredCode: ['loop']
+#  'haunted-kithmaze':
+#    hidesRunShortcut: true
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    moveRightLoopSnippet: true
+#    requiredGear: {feet: 'simple-boots', 'programming-book': 'programmaticon-i'}
+#    restrictedGear: {feet: 'leather-boots'}
+#    requiredCode: ['loop']
+#  'descending-further':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'programming-book': 'programmaticon-i'}
+#    restrictedGear: {feet: 'leather-boots'}
+#  'the-second-kithmaze':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    moveRightLoopSnippet: true
+#    requiredGear: {feet: 'simple-boots', 'programming-book': 'programmaticon-i'}
+#    restrictedGear: {feet: 'leather-boots'}
+#  'dread-door':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'}
+#    restrictedGear: {feet: 'leather-boots'}
+#  'known-enemy':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i', torso: 'tarnished-bronze-breastplate'}
+#    restrictedGear: {feet: 'leather-boots'}
+#    suspectCode: [{name: 'enemy-in-quotes', pattern: '[\'"]enemy'}]  # '
+#  'master-of-names':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses', torso: 'tarnished-bronze-breastplate'}
+#    restrictedGear: {feet: 'leather-boots'}
+#    requiredCode: ['findNearestEnemy']
+#    suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}]
+#  'lowly-kithmen':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses', torso: 'tarnished-bronze-breastplate'}
+#    restrictedGear: {feet: 'leather-boots'}
+#    requiredCode: ['findNearestEnemy']
+#    suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}]
+#  'closing-the-distance':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate', eyes: 'crude-glasses'}
+#    restrictedGear: {feet: 'leather-boots'}
+#    suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}]
+#  'tactical-strike':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate', eyes: 'crude-glasses'}
+#    restrictedGear: {feet: 'leather-boots'}
+#    suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}]
+#  'the-final-kithmaze':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#    suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}]
+#  'the-gauntlet':
+#    hidesHUD: true
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#    restrictedGear: {feet: 'leather-boots', 'right-hand': 'crude-builders-hammer'}
+#    suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}]
+#  'kithgard-gates':
+#    hidesSay: true
+#    hidesCodeToolbar: true
+#    hidesRealTimePlayback: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', torso: 'tarnished-bronze-breastplate'}
+#    restrictedGear: {'right-hand': 'simple-sword'}
+#  'defense-of-plainswood':
+#    hidesRealTimePlayback: true
+#    hidesCodeToolbar: true
+#    requiredGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer'}
+#    restrictedGear: {'right-hand': 'simple-sword'}
+#  'winding-trail':
+#    hidesRealTimePlayback: true
+#    hidesCodeToolbar: true
+#    requiredGear: {feet: 'leather-boots', 'right-hand': 'crude-builders-hammer'}
+#    restrictedGear: {feet: 'simple-boots', 'right-hand': 'simple-sword'}
+#  'patrol-buster':
+#    hidesRealTimePlayback: true
+#    hidesCodeToolbar: true
+#    requiredGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses'}
+#    restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'}
+#  'endangered-burl':
+#    hidesRealTimePlayback: true
+#    hidesCodeToolbar: true
+#    requiredGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses'}
+#    restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'}
+#  'village-guard':
+#    hidesCodeToolbar: true
+#    lockDefaultCode: true
+#    requiredGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses'}
+#    restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'}
+#  'thornbush-farm':
+#    hidesCodeToolbar: true
+#    lockDefaultCode: true
+#    requiredGear: {feet: 'leather-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses'}
+#    restrictedGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'}
+#    requiredCode: ['topEnemy']
+#  'back-to-back':
+#    hidesCodeToolbar: true
+#    requiredGear: {feet: 'leather-boots', torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses', 'right-hand': 'simple-sword', 'left-hand': 'wooden-shield'}
+#    restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'}
+#  'ogre-encampment':
+#    requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses', 'right-hand': 'simple-sword', 'left-hand': 'wooden-shield'}
+#    restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'}
+#  'woodland-cleaver':
+#    requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses', 'right-hand': 'long-sword', 'left-hand': 'wooden-shield', wrists: 'sundial-wristwatch', feet: 'leather-boots'}
+#    restrictedGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'}
+#  'shield-rush':
+#    requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {'left-hand': 'wooden-shield', 'programming-book': 'programmaticon-i'}
+#
+## Warrior branch
+#  'peasant-protection':
+#    requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {eyes: 'crude-glasses', 'programming-book': 'programmaticon-i'}
+#  'munchkin-swarm':
+#    requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#
+## Ranger branch
+#  'munchkin-harvest':
+#    requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {'programming-book': 'programmaticon-i'}
+#    allowedHeroes: ['captain', 'knight', 'samurai']
+#  'swift-dagger':
+#    requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'crude-crossbow', 'left-hand': 'crude-dagger', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {eyes: 'crude-glasses', 'programming-book': 'programmaticon-i'}
+#    allowedHeroes: ['ninja', 'trapper', 'forest-archer']
+#  'shrapnel':
+#    requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'crude-crossbow', 'left-hand': 'weak-charge', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {eyes: 'crude-glasses', 'left-hand': 'crude-dagger', 'programming-book': 'programmaticon-i'}
+#    allowedHeroes: ['ninja', 'trapper', 'forest-archer']
+#
+## Wizard branch
+#  'arcane-ally':
+#    requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {eyes: 'crude-glasses', 'programming-book': 'programmaticon-i'}
+#    allowedHeroes: ['captain', 'knight', 'samurai']
+#  'touch-of-death':
+#    requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'enchanted-stick', 'left-hand': 'unholy-tome-i', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#    allowedHeroes: ['librarian', 'potion-master', 'sorcerer']
+#  'bonemender':
+#    requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'enchanted-stick', 'left-hand': 'book-of-life-i', wrists: 'sundial-wristwatch'}
+#    restrictedGear: {'left-hand': 'unholy-tome-i', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#    requiredCode: ['canCast']
+#    allowedHeroes: ['librarian', 'potion-master', 'sorcerer']
+#
+#  'coinucopia':
+#    requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags'}
+#    restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#  'copper-meadows':
+#    requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses'}
+#    restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#  'drop-the-flag':
+#    requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses', 'right-hand': 'crude-builders-hammer'}
+#    restrictedGear: {'right-hand': 'long-sword', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#  'deadly-pursuit':
+#    requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses', 'right-hand': 'crude-builders-hammer'}
+#    restrictedGear: {'right-hand': 'long-sword', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#  'rich-forager':
+#    requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses', torso: 'tarnished-bronze-breastplate', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield'}
+#    restrictedGear: {'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#  'multiplayer-treasure-grove':
+#    requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses', torso: 'tarnished-bronze-breastplate'}
+#    restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'}
+#  'siege-of-stonehold':
+#    requiredGear: {}
+#    restrictedGear: {}
+#
+## Desert
+#  'the-dunes':
+#    requiredGear: {}
+#    restrictedGear: {}
+#  'the-mighty-sand-yak':
+#    requiredGear: {}
+#    restrictedGear: {}
+#  'oasis':
+#    requiredGear: {}
+#    restrictedGear: {}
diff --git a/app/views/editor/campaign/CampaignLevelView.coffee b/app/views/editor/campaign/CampaignLevelView.coffee
new file mode 100644
index 000000000..b27c02aeb
--- /dev/null
+++ b/app/views/editor/campaign/CampaignLevelView.coffee
@@ -0,0 +1,19 @@
+CocoView = require 'views/core/CocoView'
+
+module.exports = class CampaignLevelView extends CocoView
+  id: 'campaign-level-view'
+  template: require 'templates/editor/campaign/campaign-level-view'
+  
+  events:
+    'click .close': 'onClickClose'
+  
+  constructor: (options, @level) ->
+    super(options)
+    
+  getRenderData: ->
+    c = super()
+    c.level = @level
+    c
+
+  onClickClose: ->
+    @$el.addClass('hidden')
\ No newline at end of file
diff --git a/app/views/editor/campaign/SaveCampaignModal.coffee b/app/views/editor/campaign/SaveCampaignModal.coffee
new file mode 100644
index 000000000..69bee3904
--- /dev/null
+++ b/app/views/editor/campaign/SaveCampaignModal.coffee
@@ -0,0 +1,34 @@
+ModalView = require 'views/core/ModalView'
+template = require 'templates/editor/campaign/save-campaign-modal'
+DeltaView = require 'views/editor/DeltaView'
+
+module.exports = class SaveCampaignModal extends ModalView
+  id: 'save-campaign-modal'
+  template: template
+  plain: true
+  
+  events:
+    'click #save-button': 'onClickSaveButton'
+
+  constructor: (options, @modelsToSave) ->
+    super(options)
+    
+  getRenderData: ->
+    c = super()
+    c.modelsToSave = @modelsToSave
+    c
+
+  afterRender: ->
+    @$el.find('.delta-view').each((i, el) =>
+      $el = $(el)
+      model = @modelsToSave.find( id: $el.data('model-id'))
+      deltaView = new DeltaView({model: model})
+      @insertSubView(deltaView, $el)
+    )
+    super()
+    
+  onClickSaveButton: ->
+    @showLoading()
+    modelsBeingSaved = (model.patch() for model in @modelsToSave.models)
+    modelsBeingSaved = modelsBeingSaved
+    $.when(_.compact(modelsBeingSaved)...).done(-> document.location.reload())
\ No newline at end of file
diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee
new file mode 100644
index 000000000..16d829556
--- /dev/null
+++ b/app/views/play/CampaignView.coffee
@@ -0,0 +1,384 @@
+RootView = require 'views/core/RootView'
+template = require 'templates/play/campaign-view'
+LevelSession = require 'models/LevelSession'
+EarnedAchievement = require 'models/EarnedAchievement'
+CocoCollection = require 'collections/CocoCollection'
+Campaign = require 'models/Campaign'
+AudioPlayer = require 'lib/AudioPlayer'
+LevelSetupManager = require 'lib/LevelSetupManager'
+ThangType = require 'models/ThangType'
+MusicPlayer = require 'lib/surface/MusicPlayer'
+storage = require 'core/storage'
+AuthModal = require 'views/core/AuthModal'
+SubscribeModal = require 'views/core/SubscribeModal'
+Level = require 'models/Level'
+utils = require 'core/utils'
+
+trackedHourOfCode = false
+
+class LevelSessionsCollection extends CocoCollection
+  url: ''
+  model: LevelSession
+
+  constructor: (model) ->
+    super()
+    @url = "/db/user/#{me.id}/level.sessions?project=state.complete,levelID"
+
+module.exports = class WorldMapView extends RootView
+  id: 'campaign-view'
+  template: template
+
+  subscriptions:
+    'subscribe-modal:subscribed': 'onSubscribed'
+
+  events:
+    'click .map-background': 'onClickMap'
+    'click .level a': 'onClickLevel'
+    'click .level-info-container .start-level': 'onClickStartLevel'
+    'mouseenter .level a': 'onMouseEnterLevel'
+    'mouseleave .level a': 'onMouseLeaveLevel'
+    'mousemove .map': 'onMouseMoveMap'
+    'click #volume-button': 'onToggleVolume'
+
+  constructor: (options, @terrain='dungeon') ->
+    if options and application.isIPAdApp  # TODO: later only clear the SuperModel if it has received a memory warning (not in app store yet)
+      options.supermodel = null
+    super options
+    options ?= {}
+    
+    @campaign = new Campaign({_id:@terrain})
+    @campaign = @supermodel.loadModel(@campaign, 'campaign').model
+
+    @editorMode = options.editorMode
+    @nextLevel = @getQueryVariable 'next'
+    @levelStatusMap = {}
+    @levelPlayCountMap = {}
+    @sessions = @supermodel.loadCollection(new LevelSessionsCollection(), 'your_sessions', null, 0).model
+
+    # Temporary attempt to make sure all earned rewards are accounted for. Figure out a better solution...
+    @earnedAchievements = new CocoCollection([], {url: '/db/earned_achievement', model:EarnedAchievement, project: ['earnedRewards']})
+    @listenToOnce @earnedAchievements, 'sync', ->
+      earned = me.get('earned')
+      for m in @earnedAchievements.models
+        continue unless loadedEarned = m.get('earnedRewards')
+        for group in ['heroes', 'levels', 'items']
+          continue unless loadedEarned[group]
+          for reward in loadedEarned[group]
+            if reward not in earned[group]
+              console.warn 'Filling in a gap for reward', group, reward
+              earned[group].push(reward)
+
+    @supermodel.loadCollection(@earnedAchievements, 'achievements')
+
+    @listenToOnce @sessions, 'sync', @onSessionsLoaded
+    @getLevelPlayCounts()
+    $(window).on 'resize', @onWindowResize
+    @playAmbientSound()
+    @probablyCachedMusic = storage.load("loaded-menu-music")
+    musicDelay = if @probablyCachedMusic then 1000 else 10000
+    @playMusicTimeout = _.delay (=> @playMusic() unless @destroyed), musicDelay
+    @hadEverChosenHero = me.get('heroConfig')?.thangType
+    @listenTo me, 'change:purchased', -> @renderSelectors('#gems-count')
+    @listenTo me, 'change:spent', -> @renderSelectors('#gems-count')
+    @listenTo me, 'change:heroConfig', -> @updateHero()
+    window.tracker?.trackEvent 'Loaded World Map', category: 'World Map', ['Google Analytics']
+
+    # If it's a new player who didn't appear to come from Hour of Code, we register her here without setting the hourOfCode property.
+    elapsed = (new Date() - new Date(me.get('dateCreated')))
+    if not trackedHourOfCode and not me.get('hourOfCode') and elapsed < 5 * 60 * 1000
+      $('body').append($('<img src="http://code.org/api/hour/begin_codecombat.png" style="visibility: hidden;">'))
+      trackedHourOfCode = true
+
+    @requiresSubscription = not me.isPremium()
+
+  destroy: ->
+    @setupManager?.destroy()
+    @$el.find('.ui-draggable').draggable 'destroy'
+    $(window).off 'resize', @onWindowResize
+    if ambientSound = @ambientSound
+      # Doesn't seem to work; stops immediately.
+      createjs.Tween.get(ambientSound).to({volume: 0.0}, 1500).call -> ambientSound.stop()
+    @musicPlayer?.destroy()
+    clearTimeout @playMusicTimeout
+    super()
+
+  getLevelPlayCounts: ->
+    return # TODO: Either use the campaign object instead of hardcoded data or get the data some other way
+    return unless me.isAdmin()
+    success = (levelPlayCounts) =>
+      return if @destroyed
+      for level in levelPlayCounts
+        @levelPlayCountMap[level._id] = playtime: level.playtime, sessions: level.sessions
+      @render() if @fullyRendered
+
+    levelIDs = []
+    for campaign in campaigns
+      for level in campaign.levels
+        levelIDs.push level.id
+    levelPlayCountsRequest = @supermodel.addRequestResource 'play_counts', {
+      url: '/db/level/-/play_counts'
+      data: {ids: levelIDs}
+      method: 'POST'
+      success: success
+    }, 0
+    levelPlayCountsRequest.load()
+
+  onLoaded: ->
+    return if @fullyRendered
+    @fullyRendered = true
+    @render()
+    @preloadTopHeroes() unless me.get('heroConfig')?.thangType
+    
+  setCampaign: (@campaign) ->
+    @render()
+
+  onSubscribed: ->
+    @requiresSubscription = false
+    @render()
+
+  getRenderData: (context={}) ->
+    context = super(context)
+    context.campaign = @campaign
+    context.levels = _.values($.extend true, {}, @campaign.get('levels'))
+    for level in context.levels
+      level.position ?= { x: 10, y: 10 }
+      level.locked = not me.ownsLevel level.original
+      window.levelUnlocksNotWorking = true if level.locked and level.id is @nextLevel  # Temporary
+      level.locked = false if window.levelUnlocksNotWorking  # Temporary; also possible in HeroVictoryModal
+      level.locked = false if @levelStatusMap[level.id] in ['started', 'complete']
+      level.locked = false if me.get('slug') is 'nick'
+      level.locked = false if @editorMode
+      level.disabled = false if @levelStatusMap[level.id] in ['started', 'complete']
+      level.color = 'rgb(255, 80, 60)'
+      if level.requiresSubscription
+        level.color = 'rgb(80, 130, 200)'
+      if level.unlocksHero
+        level.unlockedHero = level.unlocksHero.originalID in (me.get('earned')?.heroes or [])
+      level.hidden = level.locked or level.disabled
+
+    # put lower levels in last, so in the world map they layer over one another properly.
+    context.campaign.levels = (_.sortBy context.campaign.levels, (l) -> l.position.y).reverse()
+
+    context.levelStatusMap = @levelStatusMap
+    context.levelPlayCountMap = @levelPlayCountMap
+    context.isIPadApp = application.isIPadApp
+    context.mapType = _.string.slugify @terrain
+    context.nextLevel = @nextLevel
+    context.forestIsAvailable = Level.levels['defense-of-plainswood'] in (me.get('earned')?.levels or [])
+    context.desertIsAvailable = Level.levels['the-mighty-sand-yak'] in (me.get('earned')?.levels or [])
+    context.requiresSubscription = @requiresSubscription
+    context.editorMode = @editorMode
+    context.adjacentCampaigns = _.filter _.values(_.cloneDeep(@campaign.get('adjacentCampaigns') or {})), (ac) ->
+      return false if ac.showIfUnlocked and ac.showIfUnlocked not in (me.get('unlocked')?.levels or [])
+      ac.name = utils.i18n ac, 'name'
+      ac.description = utils.i18n ac, 'description'
+      styles = []
+      styles.push "color: #{ac.color}" if ac.color
+      styles.push "transform: rotate(#{ac.rotation}deg)" if ac.rotation
+      ac.position ?= { x: 10, y: 10 }
+      styles.push "left: #{ac.position.x}%"
+      styles.push "top: #{ac.position.y}%"
+      ac.style = styles.join('; ')
+      return true
+    context
+
+  afterRender: ->
+    super()
+    @onWindowResize()
+    unless application.isIPadApp
+      _.defer => @$el?.find('.game-controls .btn').tooltip()  # Have to defer or i18n doesn't take effect.
+      view = @
+      @$el.find('.level, .campaign-switch').tooltip().each ->
+        return unless me.isAdmin()
+        $(@).draggable().on 'dragstop', ->
+          bg = $('.map-background')
+          x = ($(@).offset().left - bg.offset().left + $(@).outerWidth() / 2) / bg.width()
+          y = 1 - ($(@).offset().top - bg.offset().top + $(@).outerHeight() / 2) / bg.height()
+          e = { position: { x: (100 * x), y: (100 * y) }, levelOriginal: $(@).data('level-id'), campaignID: $(@).data('campaign-id') }
+          view.trigger 'level-moved', e if e.levelOriginal
+          view.trigger 'adjacent-campaign-moved', e if e.campaignID
+    @$el.addClass _.string.slugify @terrain
+    @updateVolume()
+    @updateHero()
+    unless window.currentModal or not @fullyRendered
+      @highlightElement '.level.next', delay: 500, duration: 60000, rotation: 0, sides: ['top']
+      if levelID = @$el.find('.level.next').data('level-id')
+        @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() unless @editorMode
+        pos = @$el.find('.level.next').offset()
+        @adjustLevelInfoPosition pageX: pos.left, pageY: pos.top
+        @manuallyPositionedLevelInfoID = levelID
+
+  afterInsert: ->
+    super()
+    return unless @getQueryVariable 'signup'
+    return if me.get('email')
+    @endHighlight()
+    authModal = new AuthModal supermodel: @supermodel
+    authModal.mode = 'signup'
+    @openModalView authModal
+
+  onSessionsLoaded: (e) ->
+    return if @editorMode
+    for session in @sessions.models
+      @levelStatusMap[session.get('levelID')] = if session.get('state')?.complete then 'complete' else 'started'
+    if @nextLevel and @levelStatusMap[@nextLevel] is 'complete'
+      @nextLevel = null
+    @render()
+
+  onClickMap: (e) ->
+    @$levelInfo?.hide()
+    # Easy-ish way of figuring out coordinates for placing level dots.
+    x = e.offsetX / @$el.find('.map-background').width()
+    y = (1 - e.offsetY / @$el.find('.map-background').height())
+    console.log "    x: #{(100 * x).toFixed(2)}\n    y: #{(100 * y).toFixed(2)}\n"
+
+  onClickLevel: (e) ->
+    e.preventDefault()
+    e.stopPropagation()
+    @$levelInfo?.hide()
+    levelElement = $(e.target).parents('.level')
+    levelID = levelElement.data('level-id')
+    if @editorMode
+      return @trigger 'level-clicked', levelID
+    level = _.find _.values(@campaign.get('levels')), id: levelID
+    if application.isIPadApp
+      @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show()
+      @adjustLevelInfoPosition e
+      @endHighlight()
+    else
+      if level.requiresSubscription and @requiresSubscription and not @levelStatusMap[level.id] and not level.adventurer
+        @openModalView new SubscribeModal()
+        window.tracker?.trackEvent 'Show subscription modal', category: 'Subscription', label: 'map level clicked', level: levelID
+      else if $(e.target).attr('disabled')
+        Backbone.Mediator.publish 'router:navigate', route: '/contribute/adventurer'
+        return
+      else if $(e.target).parent().hasClass 'locked'
+        return
+      else
+        @startLevel levelElement
+        window.tracker?.trackEvent 'Clicked Level', category: 'World Map', levelID: levelID, ['Google Analytics']
+
+  onClickStartLevel: (e) ->
+    levelElement = $(e.target).parents('.level-info-container')
+    @startLevel levelElement
+    window.tracker?.trackEvent 'Clicked Start Level', category: 'World Map', levelID: levelElement.data('level-id'), ['Google Analytics']
+
+  startLevel: (levelElement) ->
+    @setupManager?.destroy()
+    @setupManager = new LevelSetupManager supermodel: @supermodel, levelID: levelElement.data('level-id'), levelPath: levelElement.data('level-path'), levelName: levelElement.data('level-name'), hadEverChosenHero: @hadEverChosenHero, parent: @
+    @setupManager.open()
+    @$levelInfo?.hide()
+
+  onMouseEnterLevel: (e) ->
+    return if application.isIPadApp
+    return if @editorMode
+    levelID = $(e.target).parents('.level').data('level-id')
+    return if @manuallyPositionedLevelInfoID and levelID isnt @manuallyPositionedLevelInfoID
+    @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show()
+    @adjustLevelInfoPosition e
+    @endHighlight()
+    @manuallyPositionedLevelInfoID = false
+
+  onMouseLeaveLevel: (e) ->
+    return if application.isIPadApp
+    levelID = $(e.target).parents('.level').data('level-id')
+    return if @manuallyPositionedLevelInfoID and levelID isnt @manuallyPositionedLevelInfoID
+    @$el.find(".level-info-container[data-level-id='#{levelID}']").hide()
+    @manuallyPositionedLevelInfoID = null
+    @$levelInfo = null
+
+  onMouseMoveMap: (e) ->
+    return if application.isIPadApp
+    @adjustLevelInfoPosition e unless @manuallyPositionedLevelInfoID
+
+  adjustLevelInfoPosition: (e) ->
+    return unless @$levelInfo
+    @$map ?= @$el.find('.map')
+    mapOffset = @$map.offset()
+    mapX = e.pageX - mapOffset.left
+    mapY = e.pageY - mapOffset.top
+    margin = 20
+    width = @$levelInfo.outerWidth()
+    @$levelInfo.css('left', Math.min(Math.max(margin, mapX - width / 2), @$map.width() - width - margin))
+    height = @$levelInfo.outerHeight()
+    top = mapY - @$levelInfo.outerHeight() - 60
+    if top < 20
+      top = mapY + 60
+    @$levelInfo.css('top', top)
+
+  onWindowResize: (e) =>
+    mapHeight = iPadHeight = 1536
+    mapWidth = {dungeon: 2350, forest: 2500, desert: 2350}[@terrain] or 2350
+    aspectRatio = mapWidth / mapHeight
+    pageWidth = @$el.width()
+    pageHeight = @$el.height()
+    widthRatio = pageWidth / mapWidth
+    heightRatio = pageHeight / mapHeight
+    # Make sure we can see the whole map, fading to background in one dimension.
+    if heightRatio <= widthRatio
+      # Left and right margin
+      resultingHeight = pageHeight
+      resultingWidth = resultingHeight * aspectRatio
+    else
+      # Top and bottom margin
+      resultingWidth = pageWidth
+      resultingHeight = resultingWidth / aspectRatio
+    resultingMarginX = (pageWidth - resultingWidth) / 2
+    resultingMarginY = (pageHeight - resultingHeight) / 2
+    @$el.find('.map').css(width: resultingWidth, height: resultingHeight, 'margin-left': resultingMarginX, 'margin-top': resultingMarginY)
+
+  playAmbientSound: ->
+    return if @ambientSound
+    return unless file = {dungeon: 'ambient-dungeon', forest: 'ambient-map-grass', desert: 'ambient-desert'}[@terrain]
+    src = "/file/interface/#{file}#{AudioPlayer.ext}"
+    unless AudioPlayer.getStatus(src)?.loaded
+      AudioPlayer.preloadSound src
+      Backbone.Mediator.subscribeOnce 'audio-player:loaded', @playAmbientSound, @
+      return
+    @ambientSound = createjs.Sound.play src, loop: -1, volume: 0.1
+    createjs.Tween.get(@ambientSound).to({volume: 0.5}, 1000)
+
+  playMusic: ->
+    @musicPlayer = new MusicPlayer()
+    musicFile = '/music/music-menu'
+    Backbone.Mediator.publish 'music-player:play-music', play: true, file: musicFile
+    storage.save("loaded-menu-music", true) unless @probablyCachedMusic
+
+  preloadTopHeroes: ->
+    for heroID in ['captain', 'knight']
+      url = "/db/thang.type/#{ThangType.heroes[heroID]}/version"
+      continue if @supermodel.getModel url
+      fullHero = new ThangType()
+      fullHero.setURL url
+      @supermodel.loadModel fullHero, 'thang'
+
+  updateVolume: (volume) ->
+    volume ?= me.get('volume') ? 1.0
+    classes = ['vol-off', 'vol-down', 'vol-up']
+    button = $('#volume-button', @$el)
+    button.toggleClass 'vol-off', volume <= 0.0
+    button.toggleClass 'vol-down', 0.0 < volume < 1.0
+    button.toggleClass 'vol-up', volume >= 1.0
+    createjs.Sound.setVolume(if volume is 1 then 0.6 else volume)  # Quieter for now until individual sound FX controls work again.
+    if volume isnt me.get 'volume'
+      me.set 'volume', volume
+      me.patch()
+
+  onToggleVolume: (e) ->
+    button = $(e.target).closest('#volume-button')
+    classes = ['vol-off', 'vol-down', 'vol-up']
+    volumes = [0, 0.4, 1.0]
+    for oldClass, i in classes
+      if button.hasClass oldClass
+        newI = (i + 1) % classes.length
+        break
+      else if i is classes.length - 1  # no oldClass
+        newI = 2
+    @updateVolume volumes[newI]
+
+  updateHero: ->
+    return unless hero = me.get('heroConfig')?.thangType
+    for slug, original of ThangType.heroes when original is hero
+      @$el.find('.player-hero-icon').removeClass().addClass('player-hero-icon ' + slug)
+      return
+    console.error "WorldMapView hero update couldn't find hero slug for original:", hero
diff --git a/app/views/play/WorldMapView.coffee b/app/views/play/WorldMapView.coffee
index 9ba973c00..b5095162d 100644
--- a/app/views/play/WorldMapView.coffee
+++ b/app/views/play/WorldMapView.coffee
@@ -43,6 +43,8 @@ module.exports = class WorldMapView extends RootView
       options.supermodel = null
     @terrain ?= 'dungeon' # or 'forest', 'desert'
     super options
+    options ?= {}
+    @editorMode = options.editorMode
     @nextLevel = @getQueryVariable 'next'
     @levelStatusMap = {}
     @levelPlayCountMap = {}
@@ -97,6 +99,7 @@ module.exports = class WorldMapView extends RootView
     super()
 
   getLevelPlayCounts: ->
+    return
     return unless me.isAdmin()
     success = (levelPlayCounts) =>
       return if @destroyed
@@ -137,12 +140,12 @@ module.exports = class WorldMapView extends RootView
       level.locked = false if window.levelUnlocksNotWorking  # Temporary; also possible in HeroVictoryModal
       level.locked = false if @levelStatusMap[level.id] in ['started', 'complete']
       level.locked = false if me.get('slug') is 'nick'
+      level.locked = false if @editorMode
       level.disabled = false if @levelStatusMap[level.id] in ['started', 'complete']
       level.color = 'rgb(255, 80, 60)'
       if level.requiresSubscription
         level.color = 'rgb(80, 130, 200)'
       if level.unlocksHero
-        level.color = 'rgb(0,0,0)'
         level.unlockedHero = level.unlocksHero.originalID in (me.get('earned')?.heroes or [])
       level.hidden = level.locked or level.disabled
 
@@ -159,6 +162,7 @@ module.exports = class WorldMapView extends RootView
     context.forestIsAvailable = @startedForestLevel or (Level.levels['defense-of-plainswood'] in (me.get('earned')?.levels or []))
     context.desertIsAvailable = @startedDesertLevel or (Level.levels['the-mighty-sand-yak'] in (me.get('earned')?.levels or []))
     context.requiresSubscription = @requiresSubscription
+    context.editorMode = @editorMode
     context
 
   afterRender: ->
@@ -179,7 +183,7 @@ module.exports = class WorldMapView extends RootView
     unless window.currentModal or not @fullyRendered
       @highlightElement '.level.next', delay: 500, duration: 60000, rotation: 0, sides: ['top']
       if levelID = @$el.find('.level.next').data('level-id')
-        @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show()
+        @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() unless @editorMode
         pos = @$el.find('.level.next').offset()
         @adjustLevelInfoPosition pageX: pos.left, pageY: pos.top
         @manuallyPositionedLevelInfoID = levelID
@@ -194,6 +198,10 @@ module.exports = class WorldMapView extends RootView
     @openModalView authModal
 
   onSessionsLoaded: (e) ->
+    if @editorMode
+      @startedForestLevel = true
+      @startedDesertLevel = true
+      return
     forestLevels = (f.id for f in forest)
     desertLevels = (f.id for f in desert)
     for session in @sessions.models
@@ -249,6 +257,7 @@ module.exports = class WorldMapView extends RootView
 
   onMouseEnterLevel: (e) ->
     return if application.isIPadApp
+    return if @editorMode
     levelID = $(e.target).parents('.level').data('level-id')
     return if @manuallyPositionedLevelInfoID and levelID isnt @manuallyPositionedLevelInfoID
     @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show()
@@ -287,8 +296,8 @@ module.exports = class WorldMapView extends RootView
     mapHeight = iPadHeight = 1536
     mapWidth = {dungeon: 2350, forest: 2500, desert: 2350}[@terrain] or 2350
     aspectRatio = mapWidth / mapHeight
-    pageWidth = $(window).width()
-    pageHeight = $(window).height()
+    pageWidth = @$el.width()
+    pageHeight = @$el.height()
     widthRatio = pageWidth / mapWidth
     heightRatio = pageHeight / mapHeight
     # Make sure we can see the whole map, fading to background in one dimension.
diff --git a/server/campaigns/Campaign.coffee b/server/campaigns/Campaign.coffee
new file mode 100644
index 000000000..b0fad1371
--- /dev/null
+++ b/server/campaigns/Campaign.coffee
@@ -0,0 +1,9 @@
+mongoose = require 'mongoose'
+plugins = require '../plugins/plugins'
+
+CampaignSchema = new mongoose.Schema(body: String, {strict: false})
+
+CampaignSchema.plugin(plugins.NamedPlugin)
+CampaignSchema.plugin(plugins.TranslationCoveragePlugin)
+
+module.exports = mongoose.model('campaign', CampaignSchema)
diff --git a/server/campaigns/campaign_handler.coffee b/server/campaigns/campaign_handler.coffee
new file mode 100644
index 000000000..cc8217d81
--- /dev/null
+++ b/server/campaigns/campaign_handler.coffee
@@ -0,0 +1,70 @@
+Campaign = require './Campaign'
+Level = require '../levels/Level'
+Achievement = require '../achievements/Achievement'
+Handler = require '../commons/Handler'
+async = require 'async'
+mongoose = require 'mongoose'
+
+CampaignHandler = class CampaignHandler extends Handler
+  modelClass: Campaign
+  editableProperties: [
+    'name'
+    'i18n'
+    'i18nCoverage'
+    'ambientSound'
+    'backgroundImage'
+    'backgroundColor'
+    'backgroundColorTransparent'
+    'adjacentCampaigns'
+    'levels'
+  ]
+  jsonSchema: require '../../app/schemas/models/campaign.schema'
+
+  hasAccess: (req) ->
+    req.method is 'GET' or req.user?.isAdmin()
+
+  getByRelationship: (req, res, args...) ->
+    relationship = args[1]
+    if relationship in ['levels', 'achievements']
+      projection = {}
+      if req.query.project
+        projection[field] = 1 for field in req.query.project.split(',')
+      @getDocumentForIdOrSlug args[0], (err, campaign) =>
+        return @sendDatabaseError(res, err) if err
+        return @sendNotFoundError(res) unless campaign?
+        return @getRelatedLevels(req, res, campaign, projection) if relationship is 'levels'
+        return @getRelatedAchievements(req, res, campaign, projection) if relationship is 'achievements'
+    else
+      super(arguments...)
+      
+      
+  getRelatedLevels: (req, res, campaign, projection) ->
+    levels = campaign.get('levels') or []
+    
+    f = (levelOriginal) ->
+      (callback) ->
+        query = { original: mongoose.Types.ObjectId(levelOriginal) }
+        sort = { 'version.major': -1, 'version.minor': -1 }
+        Level.findOne(query, projection).sort(sort).exec callback
+        
+    fetches = (f(level.original) for level in _.values(levels))
+    async.parallel fetches, (err, levels) =>
+      return @sendDatabaseError(res, err) if err
+      return @sendSuccess(res, (level.toObject() for level in levels))
+  
+  
+  getRelatedAchievements: (req, res, campaign, projection) ->
+    levels = campaign.get('levels') or []
+
+    f = (levelOriginal) ->
+      (callback) ->
+        query = { related: levelOriginal }
+        Achievement.find(query, projection).exec callback
+
+    fetches = (f(level.original) for level in _.values(levels))
+    async.parallel fetches, (err, achievementses) =>
+      achievements = _.flatten(achievementses)
+      return @sendDatabaseError(res, err) if err
+      return @sendSuccess(res, (achievement.toObject() for achievement in achievements))
+
+module.exports = new CampaignHandler()
diff --git a/server/commons/Handler.coffee b/server/commons/Handler.coffee
index 2b3d7b96e..21f24796e 100644
--- a/server/commons/Handler.coffee
+++ b/server/commons/Handler.coffee
@@ -328,7 +328,7 @@ module.exports = class Handler
   put: (req, res, id) ->
     # Client expects PATCH behavior for PUTs
     # Real PATCHs return incorrect HTTP responses in some environments (e.g. Browserstack, schools)
-    return @postNewVersion(req, res) if @modelClass.schema.uses_coco_versions
+    return @sendForbiddenError(res) if @modelClass.schema.uses_coco_versions and not req.user.isAdmin()
     return @sendBadInputError(res, 'No input.') if _.isEmpty(req.body)
     return @sendForbiddenError(res) unless @hasAccess(req)
     @getDocumentForIdOrSlug req.body._id or id, (err, document) =>
diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee
index 483113c7c..6ad2d1c54 100644
--- a/server/commons/mapping.coffee
+++ b/server/commons/mapping.coffee
@@ -3,6 +3,7 @@ module.exports.handlers =
   # TODO: Disabling this until we know why our app servers CPU grows out of control.
   # 'analytics_users_active': 'analytics/analytics_users_active_handler'
   'article': 'articles/article_handler'
+  'campaign': 'campaigns/campaign_handler'
   'level': 'levels/level_handler'
   'level_component': 'levels/components/level_component_handler'
   'level_feedback': 'levels/feedbacks/level_feedback_handler'
diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee
index a49b3852a..64cddb9cb 100644
--- a/server/levels/level_handler.coffee
+++ b/server/levels/level_handler.coffee
@@ -31,6 +31,26 @@ LevelHandler = class LevelHandler extends Handler
     'i18nCoverage'
     'loadingTip'
     'requiresSubscription'
+    'adventurer'
+    'practice'
+    'disableSpaces'
+    'hidesSubmitUntilRun'
+    'hidesPlayButton'
+    'hidesRunShortcut'
+    'hidesHUD'
+    'hidesSay'
+    'hidesCodeToolbar'
+    'hidesRealTimePlayback'
+    'backspaceThrottle'
+    'lockDefaultCode'
+    'moveRightLoopSnippet'
+    'realTimeSpeedFactor'
+    'autocompleteFontSizePx'
+    'requiredCode'
+    'suspectCode'
+    'requiredGear'
+    'restrictedGear'
+    'allowedHeroes'
     'tasks'
   ]
 
diff --git a/test/server/common.coffee b/test/server/common.coffee
index a1a894f32..ddde8dd3b 100644
--- a/test/server/common.coffee
+++ b/test/server/common.coffee
@@ -26,6 +26,7 @@ GLOBAL.tv4 = require 'tv4' # required for TreemaUtils to work
 models_path = [
   '../../server/analytics/AnalyticsUsersActive'
   '../../server/articles/Article'
+  '../../server/campaigns/Campaign'
   '../../server/levels/Level'
   '../../server/levels/components/LevelComponent'
   '../../server/levels/systems/LevelSystem'
diff --git a/test/server/functional/campaign_handler.spec.coffee b/test/server/functional/campaign_handler.spec.coffee
new file mode 100644
index 000000000..cce63b7ec
--- /dev/null
+++ b/test/server/functional/campaign_handler.spec.coffee
@@ -0,0 +1,77 @@
+require '../common'
+
+levels = [
+  {
+    name: 'Level 1'
+    description: 'This is the first level.'
+    disableSpaces: true
+    icon: 'somestringyoudontneed.png'
+  }
+  {
+    name: 'Level 2'
+    description: 'This is the second level.'
+    requiresSubscription: true
+    backspaceThrottle: true
+  }
+]
+
+achievement = {
+  name: 'Level 1 Complete'
+}
+
+campaign = {
+  name: 'Campaign'
+  levels: {}
+}
+  
+levelURL = getURL('/db/level')
+achievementURL = getURL('/db/achievement')
+campaignURL = getURL('/db/campaign')
+campaignSchema = require '../../../app/schemas/models/campaign.schema'
+campaignLevelProperties = _.keys(campaignSchema.properties.levels.additionalProperties.properties)
+
+describe '/db/campaign', ->
+  it 'prepares the db first', (done) ->
+    clearModels [Achievement, Campaign, Level, User], (err) ->
+      expect(err).toBeNull()
+      loginAdmin (admin) ->
+        levels[0].permissions = levels[1].permissions = [{target: admin._id, access: 'owner'}]
+        request.post {uri: levelURL, json: levels[0]}, (err, res, body) ->
+          expect(res.statusCode).toBe(200)
+          levels[0] = body
+          request.post {uri: levelURL, json: levels[1]}, (err, res, body) ->
+            expect(res.statusCode).toBe(200)
+            levels[1] = body
+            achievement.related = levels[0].original
+            achievement.rewards = { levels: [levels[1].original] }
+            request.post {uri: achievementURL, json: achievement}, (err, res, body) ->
+              achievement = body
+              done()
+  
+  it 'can create campaigns', (done) ->
+    for level in levels.reverse()
+      campaign.levels[level.original] = _.pick level, campaignLevelProperties
+    request.post {uri: campaignURL, json: campaign}, (err, res, body) ->
+      expect(res.statusCode).toBe(200)
+      campaign = body
+      done()
+    
+describe '/db/campaign/.../levels', ->
+  it 'fetches the levels in a campaign', (done) ->
+    url = getURL("/db/campaign/#{campaign._id}/levels")
+    request.get {uri: url}, (err, res, body) ->
+      expect(res.statusCode).toBe(200)
+      body = JSON.parse(body)
+      expect(body.length).toBe(2)
+      expect(_.difference(['level-1', 'level-2'],(level.slug for level in body)).length).toBe(0)
+      done()
+      
+describe '/db/campaign/.../achievements', ->
+  it 'fetches the achievements in the levels in a campaign', (done) ->
+    url = getURL("/db/campaign/#{campaign._id}/achievements")
+    request.get {uri: url}, (err, res, body) ->
+      expect(res.statusCode).toBe(200)
+      body = JSON.parse(body)
+      expect(body.length).toBe(1)
+      done()
+      
\ No newline at end of file