How-To: Code Wrap-Around Grid Coordinates for a 2.5D Game in Swift

This is part of a free course that teaches game design and the Swift programming language. It contains examples from a real game I created called ForeverMaze. The full course includes the source code for the whole game.

This article will show you how to create an “infinite” coordinate system for a 2.5D game.

The video goes into much more depth than the article, so look there for explanations.

Download Source: Coordinate.swift

What is 2.5D?

In a “2.5D” or “isomorphic” game, the world is rotated by 45 degrees to give a sense of depth.

Here’s a screenshot from ForeverMaze:

ForeverMaze Screenshot

It can be tricky to translate between screen space (points) and grid coordinates’ space (the cartesian coordinates of the tiles). It becomes even harder if you want the world to “wrap around,” so that reaching the maximum x/y tiles seamlessly leads back to the 0/0 position.

Defining “Grid Coordinates”

One of the hardest problems in computer programming is naming. I settled on the name “coordinate” because it indicates 2D space and integer x/y values. It is notably different than “point” (which Apple uses, such as in CGPoint, to refer to a pair of x/y float values).

A point is a place on screen.

A coordinate is the location of the tile in the 2D game world.

So a character resides at a coordinate, but is drawn at a point.

One of the wonderful things about Swift is the ability to define operands. For example, if you want to be able to add two coordinates together. That is: coordinate1 + coordiante2. To allow for this, you can define the “plus” operator as follows:

func + (p1: Coordinate, p2: Coordinate) -> Coordinate { 
  return Coordinate( 
    xIndex: p1.xIndex + p2.xIndex, 
    yIndex: p1.yIndex + p2.yIndex 
  ) 
}

This makes it really easy to do math with coordinates. You may have noticed that the coordinate class has two different definitions for X and Y coordinates. There are standard x/y variables, which are unsigned integers. This means that they cannot be negative, and form the “canonical” definition of a coordinate. However, there’s also xIndex and yIndex, which are signed variables useful for doing math on a coordinate. This will become important in the next section.

Converting A Coordinate to a Screen Point

Once you have coordinates, the next thing you’ll need to do is be able to determine where to display them on-screen. The 2.5D nature of the game makes this a bit tricky, since “up” (or “north”) is actually towards the top-right corner of the window.

  static let tileSize = CGSizeMake(32, 32) // size in points of the tile's artwork

  func coordinateToPosition(coord:Coordinate) -> CGPoint {  
    let points = CGPoint(x: CGFloat(coord.x) * tileSize.width, y: CGFloat(coord.y) * tileSize.height)
    return CGPoint(x: points.x + points.y, y: (points.y - points.x)/2)
  }

Determining if a Grid Coordinate is On-Screen

One essential thing you’ll need to be able to do is figure out if any given coordinate is currently on the screen. This will allow you to load (and unload) tiles as-needed while the player walks around.

  var center:Coordinate = Coordinate(x: 0, y: 0) // the current center-coordinate of the screen, where the player is standing

  func isCoordinateOnScreen(coord: Coordinate, bufferPx: CGFloat = 0) -> Bool {
    let maxDist = CGSizeMake(self.size.width / 2 + bufferPx, self.size.height / 2 + bufferPx)
    let testCoord = Coordinate(x: 50, y: 50)
    let offset = coord - self.center + testCoord
    let dist = self.coordinateToPosition(offset) - self.coordinateToPosition(testCoord) + CGPoint(x: tileSize.width/2, y: tileSize.height/2)
    return abs(dist.x) < maxDist.width && abs(dist.y) < maxDist.height
  }

The bufferPx  allows you to add some optional offset from the edge of the screen. For example, if you passed in 100, a tile which was 100 points off-screen would be considered on-screen. This is useful because you generally want to have a “buffer region” around the screen where objects are being loaded and unloaded, thus guaranteeing that by the time they’re actually on-screen they’re actually ready to be shown.

You’ll notice that I use a testCoord  to offset the coordinate. This ensures that we don’t run into any edge-wrapping problems that might otherwise occur (if, say, the input coordinate was at 0x0 ). Speaking of edge-wrapping…

Handling World-Wrapping

When the player steps off the “top” edge of the world, we need to wrap around so that he immediately appears back at the bottom of the world. This is trickier than it sounds because we can’t show a “seam” around the edge of the world.

Let’s say the world is 100×100  tiles large. If I’m standing at myCoordinate = 50×99 , this means the tile directly north of me is at otherCoordinate = 50×0 . If you were to pass these two coordinates into the coordinateToPosition  function above, they’d actually not be next to each other. The second position would be all the way at the bottom of the map!

Really, each coordinate has 4 possible variations of where it could be positioned, based upon world-wrapping. To accommodate this, let’s rewrite our coordinateToPosition  function to add a new input variable called closeToCenter . When true , it enforces that the resultant point should be the one closest to the current center. Note that this function exists on the SKScene  subclass, meaning that self.size  is the size (in points) of the screen.

func coordinateToPosition(pos:Coordinate, closeToCenter:Bool = false) -> CGPoint {
    // Since positions can wrap around the world, we choose the point which is closest to our current center.
    var xPos = Int(pos.x)
    var yPos = Int(pos.y)
    if closeToCenter {
      let buffer = UInt(self.size.width / CGFloat(tileSize.width))
      if self.center.x >= (self.worldSize.width - buffer) && pos.x < buffer {
        xPos += Int(self.worldSize.width)
      }
      else if self.center.x <= buffer && pos.x >= (self.worldSize.width - buffer) {
        xPos = pos.xIndex
      }
      if self.center.y >= (self.worldSize.height - buffer) && pos.y < buffer {
        yPos += Int(self.worldSize.height)
      }
      else if self.center.y <= buffer && pos.y >= (self.worldSize.height - buffer) {
        yPos = pos.yIndex
      }
    }
    
    let pixels = CGPoint(x: CGFloat(xPos) * IsoScene.tileSize.width, y: CGFloat(yPos)*IsoScene.tileSize.height)
    return CGPoint(x:pixels.x + pixels.y, y: (pixels.y - pixels.x)/2)
  }

Phew! It’s a bit more complicated, and there are other ways to go about it, but this gives us the tool we need to allow for a seamless world. When the player is about to take a step, we simply check if the new coordinate is going to wrap around the world. If it will, we redraw the world based upon the new center point before continuing.

  // oldCoord is the coordinate the player started at, i.e., the value of self.center *before* the step
  // self.worldSize is the number of coordinates in the world, i.e., 100x100
  let point = self.coordinateToPosition(self.center, closeToCenter: false)
  let travelAmount = self.center - oldCoord

  if oldCoord.willWrapAroundWorld(self.center, worldSize: self.worldSize, threshold: 1) {
      // Before doing step calculations, teleport around the edge of the world.
      let testCoord = Coordinate(x: 5, y: 5)
      let testPoint = self.coordinateToPosition(testCoord, closeToCenter: false)
      let travelDist = testPoint - self.coordinateToPosition(testCoord + travelAmount, closeToCenter: false)
      let teleportPoint = point + travelDist
      self.playerSprite?.position = teleportPoint
      oldPoint = teleportPoint
      
      // Redraw the world, otherwise we just teleported into nothingness.
      for tile in self.tiles.values {
        // redraw the contents of this tile...
      }
    }

More Help

Grid coordinates in a wrap-around world can be tricky. Hopefully this post gave you all the tools you need to implement them in your game. Let me know in the comments if anything was unclear!

If you enjoyed this post, why not enroll in the free course? It contains hours of video content, source code downloads, examples and more. I cover many different intermediate and advanced topics for creating a real-time game in Swift with SpriteKit and Firebase. You can check out the complete game at ForeverMaze.com.

[author]

Leave A Comment

You must be logged in to post a comment.

Back to Top