Skip to content

Instantly share code, notes, and snippets.

@OhTerryTorres
Last active July 14, 2023 15:16
Show Gist options
  • Save OhTerryTorres/f1e3ec10258af81650b055c221e02cef to your computer and use it in GitHub Desktop.
Save OhTerryTorres/f1e3ec10258af81650b055c221e02cef to your computer and use it in GitHub Desktop.
Creating levels for a SpriteKit game using JSON

Making Levels in a SpriteKit game from JSON

I’m making a SpriteKit game for a wine shop in Boston’s North End. We want it to

  1. have an old-school, chunky, arcadey hack-and-slash feel
  2. properly convey that old-school feel using effortless touch controls
  3. have its levels built (and potentially updated after launch) using an external data file

I want to talk about how I plan on implementing that last one: level data from an external file.

It’s all dictionaries to me

Let’s start with the file we’ll be getting data from to make our levels. I’m using a JSON file for its ease and legibility. Each level has a number, a name, successive waves of enemies, descriptions of those enemies and their movement patterns (they move on a custom grid I created), and a reward for completing the level – in the case of this game, magical wine that provides the player with new abilities.

{
    "1": {

	"name" : "Hanover & Prince",
	"waves": [{"enemies" : [{ "type": "normal",
				"position": { "x": 1, "y": 3 }
				},

				{ "type": "normal",
				"position": { "x": 3, "y": 3 }
				}]
		  }],
	"wine": {
	    "name" : "Riesling",
	    "gesture" : "Swipe Player",
	    "color" : { "r" : 0.65, "g" : 0.81, "b" : 0.24, "a" : 1.0 },
	    "skill" : {
		"type" : "thrust",
		"attackTime" : 0.05 }
	}
    },
    "2": {

	"name" : "Hanover & Fleet",
	"waves": [{"enemies" : [{ "type": "normal",
				"position": { "x": 0, "y": 2 },
				"route": [{ "x": 0, "y": 2 },
					  { "x": 1, "y": 2 },
					  { "x": 2, "y": 2 },
					  { "x": 3, "y": 2 },
					  { "x": 4, "y": 2 }],
				"doesReverseRoute" : true },

				{ "type": "normal",
				"position": { "x": 4, "y": 4 },
				"route": [{ "x": 4, "y": 4 },
					  { "x": 3, "y": 4 },
					  { "x": 2, "y": 4 },
					  { "x": 1, "y": 4 },
					  { "x": 0, "y": 4 }],
				"doesReverseRoute" : true },

				{ "type": "mage",
				"position": { "x": 2, "y": 6 },
				"attackDelayTime" : 2.0 }]
		  }],
	"wine": {
	    "name" : "Tavel Rosé",
	    "gesture" : "Hold",
	    "color" : { "r" : 1.00, "g" : 0.42, "b" : 0.66, "a" : 1.0 },
	    "skill" : {
		"type" : "guarding" }
	}
    },
    "3" : {

	"name" : "Rachel Revere Square",
	"waves": [{ "enemies" : [{ "type": "normal",
				 "position": { "x": 0, "y": 1 },
				 "route": [{ "x": 0, "y": 1 },
					   { "x": 0, "y": 2 },
					   { "x": 1, "y": 2 },
					   { "x": 1, "y": 3 },
					   { "x": 2, "y": 3 },
					   { "x": 2, "y": 4 },
					   { "x": 3, "y": 4 },
					   { "x": 3, "y": 5 },
					   { "x": 4, "y": 5 } ],
				 "doesReverseRoute" : true },

				 { "type": "normal",
				 "position": { "x": 4, "y": 5 },
				 "route": [{ "x": 4, "y": 5 },
					   { "x": 4, "y": 4 },
					   { "x": 3, "y": 4 },
					   { "x": 3, "y": 3 },
					   { "x": 2, "y": 3 },
					   { "x": 2, "y": 2 },
					   { "x": 1, "y": 2 },
					   { "x": 1, "y": 1 },
					   { "x": 0, "y": 1 } ],
				 "doesReverseRoute" : true },

				 { "type": "mage",
				 "position": { "x": 2, "y": 0 },
				 "route": [{ "x": 2, "y": 0 },
					   { "x": 1, "y": 0 },
					   { "x": 0, "y": 0 },
					   { "x": 0, "y": 1 },
					   { "x": 0, "y": 2 },
					   { "x": 0, "y": 3 },
					   { "x": 0, "y": 4 },
					   { "x": 0, "y": 5 },
					   { "x": 0, "y": 6 },
					   { "x": 1, "y": 6 },
					   { "x": 2, "y": 6 },
					   { "x": 3, "y": 6 },
					   { "x": 4, "y": 6 },
					   { "x": 4, "y": 5 },
					   { "x": 4, "y": 4 },
					   { "x": 4, "y": 3 },
					   { "x": 4, "y": 2 },
					   { "x": 4, "y": 1 },
					   { "x": 4, "y": 0 },
					   { "x": 3, "y": 0 }]},

				 { "type": "mage",
				 "position": { "x": 2, "y": 6 },
				 "route": [{ "x": 2, "y": 6 },
					   { "x": 3, "y": 6 },
					   { "x": 4, "y": 6 },
					   { "x": 4, "y": 5 },
					   { "x": 4, "y": 4 },
					   { "x": 4, "y": 3 },
					   { "x": 4, "y": 2 },
					   { "x": 4, "y": 1 },
					   { "x": 4, "y": 0 },
					   { "x": 3, "y": 0 },
					   { "x": 2, "y": 0 },
					   { "x": 1, "y": 0 },
					   { "x": 0, "y": 0 },
					   { "x": 0, "y": 1 },
					   { "x": 0, "y": 2 },
					   { "x": 0, "y": 3 },
					   { "x": 0, "y": 4 },
					   { "x": 0, "y": 5 },
					   { "x": 0, "y": 6 },
					   { "x": 1, "y": 6 }],
				 "attackDelayTime" : 2.0 }]
		  }],
	"wine": {
	    "name" : "Zinfandel",
	    "gesture" : "Swipe",
	    "color" : { "r" : 0.49, "g" : 0.02, "b" : 0.35, "a" : 1.0 },
	    "skill" : {
		"type" : "slash",
		"recoverTime" : 0.35 }
	}
    }
}

Now when our very first scene, the LevelSelectScene, is initialized, it reads and serializes the JSON file, then uses each JSON object within to initialize each level and then add it to a Levels array. (Later this logic might be extracted from the scene a delegate)

init(size: CGSize) {
    super.init(size: size)
    do {
        if let file = Bundle.main.url(forResource: "levels", withExtension: "json") {
            let data = try Data(contentsOf: file)
            let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
            for (key, value) in json! {
                let levelNumber = Int(key) ?? 0
                if let levelDict = value as? [String : Any] {
                    let level = Level(levelNumber: levelNumber, dict: levelDict)
                    levels += [level]
                }
            }
        }
    } catch {
        print("Error deserializing JSON: \(error)")
    }
}

What follows is a waterfall of dictionary-based initializations.

Level is a struct that looks like this. It initializes its wine property directly using a dictionary derived from the original JSON. In contrast, rather than taking all of the enemy data and initializing a bunch of enemy objects at once, it instead stores the dictionaries that will be used to create those enemies for later, when the EnemyWaveManager adds them directly to the active scene.

struct Level {
    let levelNumber : Int
    let name : String
    var wine: Wine?
    var enemyWaveDicts : [[String:Any]] = []

    init(levelNumber: Int, dict: [String : Any])  {
	self.levelNumber = levelNumber
	self.name = dict["name"] as? String ?? "??"
	if let wineDict = dict["wine"] as? [String : Any] {
	    self.wine = Wine(dict: wineDict)
	} else {
	    self.wine = nil
	}
	self.enemyWaveDicts =  dict["waves"] as? [[String : Any]] ?? []
    }
}

Wine is a class that looks like this. The wine objects basically act as weapons or equipment in the game. It has a color that’s applied to sprites generated from it (swords, shields, energy bolts, w/e), a gesture enum associated with it (tap, swipe, hold), and most importantly, a skill that’s used to actually create a series of SKAction that become the core mechanics of the game.

class Wine {
    let name : String
    let color : UIColor
    let gesture : Gesture
    var skill : WineSkill
    var active = false

    init(dict: [String: Any]) {
	self.name = dict["name"] as? String ?? "??"
	self.gesture = Gesture(rawValue: dict["gesture"] as? String ?? "None") ?? .none

	if let colorDict = dict["color"] as? [String : Any] {
	    let red = colorDict["r"] as? Float ?? 0.0
	    print("red \(red)")
	    let green = colorDict["g"] as? Float ?? 0.0
	    print("green \(green)")
	    let blue = colorDict["b"] as? Float ?? 0.0
	    print("blue \(blue)")
	    let alpha = colorDict["a"] as? Float ?? 0.0
	    print("alpha \(alpha)")
	    self.color = UIColor(colorLiteralRed: red, green: green, blue: blue, alpha: alpha)
	} else {
	    self.color = .white
	}

	self.skill = MockSkill()
	if let skillDict = dict["skill"] as? [String:Any] {
	    if let typeString = skillDict["type"] as? String {
		if let type = SkillType(rawValue: typeString) {
		    switch type {
		    case .thrust:
			self.skill = ThrustSkill(wine: self, dict: skillDict)
		    case .slash:
			self.skill = SlashSkill(wine: self, dict: skillDict)
		    case .guarding:
			self.skill = GuardingSkill(wine: self, dict: skillDict)
		    }
		}
	    }
	}
    }
}

ThrustSkill, SlashSkill, and GuardingSkill are all structs that adhere to a WineSkill protocol, asserting that each skill must have a reference to its associated wine and a function that returns an SKAction based on each skill. Via the JSON file, though, an existing skill can be altered to have different properties – shorter or longer wind-up or cool-down times, how far an enemy is pushed away, etc.

struct ThrustSkill : WineSkill, Melee, Knockback {
    weak var wine: Wine?
    var meleeComponent: MeleeComponent
    var knockbackComponent: KnockbackComponent

    var movesPlayer = true

    init(wine: Wine?, dict: [String : Any]) {
	self.wine = wine
	self.meleeComponent = MeleeComponent(dict: dict)
	self.knockbackComponent = KnockbackComponent(dict: dict)

	if let movesPlayer = dict["movesPlayer"] as? Bool {
	    self.movesPlayer = movesPlayer
	}
    }

    func action(player: Player, touchPath: TouchPath?, target: GameSprite?) -> SKAction {
	wine?.active = true
	return SKAction.run {
	    //… cool stuff like making a sword appear and moving the player in the direction of the attack
	}
    }
}

The skills in turn have Components that further breakdown each skill’s functionality. They in turn are made the sole property of their respective protocols which are, in turn, extended so that each of the component’s properties can be treated directly as properties of the skill to which with they’re associated.

What’s especially nice about using these components is that they can all have default values. That way the JSON only needs to address the properties of a component that they actually want to change.

protocol Component { }

struct KnockbackComponent : Component {
    var knockbackRate : Float = 3.0
    var blastEnabled : Bool = false

    init(dict: [String : Any]) {
	if let knockbackRate = dict["knockbackRate"] as? Float {
	    self.knockbackRate = knockbackRate
	}
	if let blastEnabled = dict["blastEnabled"] as? Bool {
	    self.blastEnabled = blastEnabled
	}
    }
}
protocol Knockback  {
    var knockbackComponent: KnockbackComponent { get set }
}
extension Knockback {
    var knockbackRate : Float {
	get { return knockbackComponent.knockbackRate }
	set { knockbackComponent.knockbackRate = newValue }
    }
    var blastEnabled : Bool {
	get { return knockbackComponent.blastEnabled }
	set { knockbackComponent.blastEnabled = newValue }
    }
}

Anyway, jumping back a bit, what happens when we actually want to run the level?

Once the right level is selected in the LevelSelectScene, the GameScene is a initialized with the chosen level and presented.

let scene = GameScene(size: self.view!.bounds.size, level: level)
scene.scaleMode = .fill
self.view!.presentScene(scene)

The GameScene sets itself up using the level that was injected into it.

let level : Level
let player = Player()
var contactDelegate : GamePhysicsContactDelegate!
var enemyWaveManager : EnemyWaveManager!
var grid : PositionGrid!
init(size: CGSize, level: Level) {
	self.level = level
	self.player = Player()
	super.init(size: size)

	self.grid = PositionGrid(scene: self)
	player.position = grid.point[3][2] // center, more or less
	addChild(player)
	self.contactDelegate = GamePhysicsContactDelegate(scene: self)

	// Position Grid need to exist before Enemy Wave Manager
	self.enemyWaveManager = EnemyWaveManager(scene: self)

	physicsBody = SKPhysicsBody(edgeLoopFrom: self.frame)
	physicsBody?.isDynamic = false
	physicsBody?.categoryBitMask = Contact.wallCategory
	physicsWorld.contactDelegate = contactDelegate
	physicsWorld.gravity = CGVector.zero
    }

The PositionGrid creates a grid system that all of the character sprites move on. The “grid” is, in reality, a 2D array of CGPoints evenly spaced across the screen. (That’s kind of beside the whole JSON initialization thing, though)

The EnemyWaveManager is what extracts the information needed to make each enemy from the enemyWaveDicts array we made way back in the Level struct initializer.

struct EnemyWaveManager {
    let level : Level
    let scene : GameScene
    var currentWaveIndex : Int = 0

    init(scene: GameScene) {
        self.scene = scene
        self.level = scene.level
        prepareNextWave()
    }

    mutating func prepareNextWave() {
        let enemies = getWaveOfEnemiesFromLevel()
    
        if enemies.count > 0 {
            addEnemiesToScene(enemies: enemies)
        }
        currentWaveIndex += 1
    }

    func getWaveOfEnemiesFromLevel() -> [Enemy] {
        guard currentWaveIndex < level.enemyWaveDicts.count else { return [] }
        let waveDict = level.enemyWaveDicts[currentWaveIndex]
        guard let enemyDicts = waveDict["enemies"] as? [[String : Any]] else { return [] }

        return arrayOfEnemysFromArrayOfDictionaries(enemyDicts)
    }

    func addEnemiesToScene(enemies: [Enemy]) {
        for enemy in enemies {
            scene.addChild(enemy)
        }
    }
	
	func arrayOfEnemysFromArrayOfDictionaries(_ dicts: [[String : Any]]) -> [Enemy] {
    var enemies : [Enemy] = []
        for dict in dicts {
            let enemy : Enemy
            let type = dict["type"] as? String ?? "normal"
            switch type {
            case "normal":
                enemy = Enemy(dict: dict, onPositionGrid: scene.grid)
            case "mage":
                enemy = EnemyMage(dict: dict, onPositionGrid: scene.grid, target: scene.player)
            default:
                print("Error deciding enemy type")
                enemy = Enemy()
            }
            enemies += [enemy]
        }
        return enemies
	}
}

Finally, Enemy is a SKSpriteNode subclass with these initializers.

init() {
    super.init(texture: nil, color: .red, size: CGSize(width: 40.0, height: 40.0))
    name = "Enemy"
    zRotation = CGFloat(Double.pi / 2)
    physicsBody?.collisionBitMask = Contact.wallCategory
    physicsBody?.categoryBitMask = Contact.enemyCategory
    physicsBody?.contactTestBitMask = Contact.playerAttackCategory | Contact.enemyCategory | Contact.wallCategory | Contact.enemyAttackCategory
}

convenience init(dict: [String : Any], onPositionGrid grid: PositionGrid) {
    self.init()
    
    if let x = (dict["position"] as? [String : Any])?["x"] as? Int, let y = (dict["position"] as? [String:Any])?["y"] as? Int {
        self.position = grid.point[y][x]
    }
    
    if let routeDict = dict["route"] as? [[String : Any]] {
        var route : [CGPoint] = []
        for pointDict in routeDict {
            let x = pointDict["x"] as? Int ?? 0
            let y = pointDict["y"] as? Int ?? 0
            route += [grid.point[y][x]]
        }
        if let doesReverseRoute = dict["doesReverseRoute"] as? Bool {
            if doesReverseRoute {
                route += route.reversed()
            }
        }
        executeTravelAction(withPoints: route)
    }
}

… and this is executeTravelAction().

func executeTravelAction(withPoints points: [CGPoint]) {
    var moveRoutine : [SKAction] = []
    for point in points {
        let moveAction = SKAction.move(to: point, duration: travelTime)
        let waitAction = SKAction.wait(forDuration: travelWaitTime)
        moveRoutine += [moveAction, waitAction]
    }
    self.travelAction = SKAction.repeatForever(SKAction.sequence(moveRoutine))
    run(travelAction, withKey: "travelAction")
}

So, basically

Using this method, nearly every game object of importance has an initializer that requires a [String : Any] dictionary.

Loading game data from an external source in this way requires that you set up your game objects to receive the right data with the right keys, and that you write your data file with precision. All of this is dependent on your game systems being constructed in a modular way.

Potential improvements are dependent on how the game’s scope changes.

  • Test levels currently only consist of a single wave of enemies each, so there’s more that might come for actually using them. Currently each “wave” object only has a property called “enemies”. Potentially they may also have properties that decide of there is a pause before their appearance, or if their appearance overlaps with the previous wave, or if a customized special effect (like a lighting cue or flash) precedes their appearance.
  • This in turn might necessitate an EnemyWave class, which the EnemyWaveManager would interact with, rather than creating Enemies directly.
  • Wine objects and their skills could be even more modular, with customized sounds and sprites from the JSON.
  • A truly out-there level of customization would involve setting up the JSON to somehow construct SKActions that would be in turn used to create each wine’s skill actions. The current SKActions require varying amounts of grouping/sequencing and synchronicity –– the game would have be set up to receive and decipher many possible combinations. The effort might not be worth it when the current system of preset skills works. Time will tell.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment