Discrete and Continuous Values

Learn how to handle floating point precision issues when programming game physics and transformations between coordinate spaces.
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Get Started for Free
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated

When developing games, we frequently need to work with both continuous values (like physics positions) and discrete ones (like screen pixels).

In this lesson, we'll explore the challenges of working with floating point numbers in different coordinate spaces and learn practical techniques to avoid common pitfalls related to precision and comparison.

We’ll be using the Vec2 struct we created earlier in the course. A complete version of it is available below:

#pragma once
#include <iostream>

struct Vec2 {
  float x;
  float y;

  float GetLength() const {
    return std::sqrt(x * x + y * y);
  }

  float GetDistance(const Vec2& Other) const {
    return (*this - Other).GetLength();
  }

  Vec2 Normalize() const {
    return *this / GetLength();
  }

  Vec2 operator*(float Multiplier) const {
    return Vec2{x * Multiplier, y * Multiplier};
  }

  Vec2 operator/(float Divisor) const {
    if (Divisor == 0.0f) { return Vec2{0, 0}; }

    return Vec2{x / Divisor, y / Divisor};
  }

  Vec2& operator*=(float Multiplier) {
    x *= Multiplier;
    y *= Multiplier;
    return *this;
  }

  Vec2& operator/=(float Divisor) {
    if (Divisor == 0.0f) { return *this; }

    x /= Divisor;
    y /= Divisor;
    return *this;
  }

  Vec2 operator+(const Vec2& Other) const {
    return Vec2{x + Other.x, y + Other.y};
  }

  Vec2 operator-(const Vec2& Other) const {
    return *this + (-Other);
  }

  Vec2& operator+=(const Vec2& Other) {
    x += Other.x;
    y += Other.y;
    return *this;
  }

  Vec2& operator-=(const Vec2& Other) {
    return *this += (-Other);
  }

  Vec2 operator-() const {
    return Vec2{-x, -y};
  }
};

inline Vec2 operator*(float M, const Vec2& V) {
  return V * M;
}

inline std::ostream& operator<<(
  std::ostream& Stream, const Vec2& V) {
  Stream << "{ x = " << V.x
    << ", y = " << V.y << " }";
  return Stream;
}

Discrete and Continuous Spaces

When we write code that positions and moves objects through spaces using floating point numbers, we have to consider the fact that, unlike discrete integers, floating point numbers are continuous.

  • Discrete numbers, such as those represented by an int, can only take one of a specific set of values. For example, there are only two possible integers between 22 and 55 - the integers 33 and 44
  • Continuous numbers, such as those represented by a float, can potentially take any value. For example, there are conceptually endless floating point numbers between 22 and 55, with values such as 2.42.4, 2.412.41, and 2.400000012.40000001.

These concepts extend to spaces too. For example:

  • An SDL_Surface is a discrete space, as there are a limited number of possible positions within the space. Each possible position is represented by an x and y integer and corresponds to a pixel on the surface.
  • World space is typically set up as continuous, where we use floating-point numbers to represent a conceptually endless range of possible positions in the space. This gives us, or the simulations we create, the ability to control objects’ positions and movements much more accurately.

One implication of working with continuous spaces is that we need to become accustomed to working with approximations. Even though we tend to think of floating point numbers as having simple values like 2.42.4, we shouldn’t expect to be handling such well-rounded numbers in a continuous space. A value in such a space is just as likely to be 2.4000003172.400000317 as it is to be exactly 2.42.4.

Floating Point Comparisons

The main scenario in which we need to be aware of the inherent approximations of continuous values and work around them is when we’re trying to do equality comparisons.

For example, our gameplay logic might need to determine whether two objects are in the same position, so it might seem reasonable to write a check like this:

struct Object{
  Vec2 Position;
};

bool IsInSamePosition(Object& A, Object& B) {
  return A.Position.x == B.Position.x
    && A.Position.y == B.Position.y; 
}

However, when our objects are being simulated in a continuous space using floating point numbers, it’s very unlikely that their positions would be exactly equal. As such, an equality comparison using == will almost always return false, even if the object positions are so similar we would want to consider them as being equal for our simulation or gameplay needs.

For example, the numbers 2.39999958 and 2.40000031 are not entirely equal. However in most contexts, including computer graphics, we’d consider them close enough to be treated as equal. Therefore, we need to create alternatives to the == and != operators that check if objects are "close enough".

The common way of doing this is creating a function that accepts our two objects, and returns true if the difference between them is sufficiently small. This "sufficiently small" tolerance level is sometimes offered as a third argument with a default value, allowing users of the function to specify how small the difference between the values must be for them to be considered equal.

The following is an example of such a function that compares float objects, and an overload that uses similar concepts for comparing Vec2 objects. If it’s unclear why we’re using std::abs() here, we cover that in the next section:

#include <cmath> // For std::abs

// For Float Comparison
bool NearlyEqual(float A, float B,
                 float Tolerance = 0.00001) {
  return std::abs(A - B) < Tolerance;
}

// For Vec2 Comparison
bool NearlyEqual(const Vec2& A, const Vec2& B,
                 float Tolerance = 0.00001) {
  return std::abs(A.x - B.x) < Tolerance
    && std::abs(A.y - B.y) < Tolerance;
}

Our NearlyEqual() function would replace the == and != operators as follows:

if (A == B) {/*...*/} 
if (NearlyEqual(A, B)) {/*...*/} 

if (A != B) {/*...*/} 
if (!NearlyEqual(A, B)) {/*...*/}

A similar rationale applies to other comparisons, such as <= and <. For example, if A and B are approximately equal, we may not want to consider A to be meaningfully smaller than B even if A < B is technically true.

As such, we may also need to implement alternatives to these operators that make this distinction, should we ever need such logic. However, in practice, this is much less important than the == and != alternatives.

Avoiding Floating Point Numbers

Unlike with discrete numbers like integers, the underlying limitations of computer architecture makes exact floating point representations difficult, even when we’re not doing fine-grained simulations.

This limitation leads to quirks where approximations are used even in contexts where we wouldn’t expect them, such as when we’re directly using somewhat round numbers. For example, the result of 0.1 + 0.2 is not exactly equal to 0.3, which can result in unexpected behavior:

#include <iostream>

int main() {
  if (0.1 + 0.2 == 0.3) {
    std::cout << "Obviously equal";
  } else {
    std::cout << "Somehow not equal";
  }
}
Somehow not equal

As such, it’s often a good idea to determine if our problem (or the entire program) has a reasonable way to avoid floating point numbers entirely.

For example, when dealing with financial values, it would seem intuitive to store a value like $3.57 as the floating point number 3.57. Instead, it is typically recommended that we store such values as cents rather than dollars. This means we would represent a value like $3.57 as the discrete integer 357, avoiding floating point numbers entirely.

There are rarely such simple alternatives that we can use when working on things like graphics and physics simulations. In those contexts, we just embrace floating point numbers, remaining mindful of their inexact nature and working around it when required:

#include <iostream>

bool NearlyEqual(float, float){/*...*/} int main() { if (NearlyEqual(0.1 + 0.2, 0.3)) { std::cout << "Obviously equal"; } else { std::cout << "Somehow not equal"; } }
Obviously equal

Vectors and Absolute Values

This previous code is an example where we use the concept of an absolute value, which we can revisit briefly here as it has some renewed meaning in the context of vectors.

As a reminder, we can consider taking the absolute value of a number to be removing that number’s negative component, if it has one.

For example, the absolute value of 3-3 is 33. If a number is not negative, the absolute value is the same as the number itself, so the absolute value of 33 is 33.

To get the absolute value of an expression in C++, we can use std::abs() within the <cmath> standard library or, alternatively, SDL_abs() if we’re using SDL:

#include <iostream>
#include <cmath> // For std::abs
#include <SDL.h> // For SDL_abs

int main() {
  float A{3};
  float B{-3};

  std::cout << "A: " << A;
  std::cout << ", abs(A): " << std::abs(A);

  std::cout << "\nB: " << B;
  std::cout << ", abs(B): " << SDL_abs(B);
}
A: 3, abs(A): 3
B: -3, abs(B): 3

In our NearlyEqual() function, we’re using the absolute value to make the argument order of AA and BB unimportant. ABA - B is not necessarily the same as BAB - A, but the absolute values of ABA - B and BAB - A will be the same:

#include <iostream>
#include <cmath> // For std::abs

int main() {
  float A{1.001};
  float B{0.999};

  using std::abs;
  std::cout << "A - B:    " << A - B;
  std::cout << "\nB - A:    " << B - A;
  std::cout << "\nabs(A-B): " << abs(A - B);
  std::cout << "\nabs(B-A): " << abs(B - A);
}
A - B:    0.00200003
B - A:    -0.00200003
abs(A-B): 0.00200003
abs(B-A): 0.00200003

Conceptually, we can imagine an expression like abs(A - B) calculating the distance between A and B, without caring about the direction. In mathematical notation, the absolute value of a number is represented by vertical bars, for example:

3=3 \lvert -3 \rvert = 3

This vertical bar notation is the same one we use when referencing the magnitude of a vector, as in V\lvert V \rvert, because they’re fundamentally the same idea. The absolute value of some number, x\lvert x \rvert, is how far away that number is from 00, whilst the magnitude of some two-dimensional vector, (x,y)\lvert (x, y) \rvert, is the straight-line distance from that vector to (0,0)(0, 0) - the origin.

We can even consider a number like 3-3 to be a one-dimensional vector in a one-dimensional space, which makes the similarity even more obvious. A one-dimensional space is simply a number line:

Diagram showing a number line

Continuous to Discrete Conversions

After we’ve run our simulation and calculated the position of all the objects in our continuous world space, we’ll often need to transform these vectors to equivalent positions in a discrete space. For example, the screen space representation using SDL_Surface is discrete - there is a fixed quantity of pixels on our surface, represented by integers.

We cover how to implement this full world space to screen space pipeline in the next chapter, but for now, we should note that converting a floating point number directly to an integer discards the floating point component. This is generally less accurate than we’d like - for example, it results in 3.83.8 being converted to 33, even though it’s closer to 44:

#include <iostream>

int main() {
  std::cout << "Converting 3.8 to int: "
    << static_cast<int>(3.8);
}
Converting 3.8 to int: 3

To fix this, we can explicitly round our floating point numbers to the nearest integer. We can do this using std::round() from <cmath>, or SDL_round() if we’re using SDL:

#include <iostream>

#include <cmath> // For std::round
#include <SDL.h> // For SDL_round

int main() {
  std::cout << "Rounding 3.8 to int: "
    << std::round(3.8);
  std::cout << "\nRounding 3.8 to int: "
    << SDL_round(3.8);
}
Rounding 3.8 to int: 4
Rounding 3.8 to int: 4

In the following example, we incorporate this rounding into our transformation function from world space to screen space. This ensures our Vec2 components are integer values:

#include <cmath> // For std::round

Vec2 ToScreenSpace(const Vec2& Pos) {
  return {
    std::round(Pos.x * 0.5f),
    std::round((Pos.y * -0.5f) + 300)
  };
}

Even though these values are now integers, they are being stored in float containers - the x and y variables of our Vec2 type. This is not necessarily a problem as almost every implementation of the float type uses the IEEE 754 standard, which can accurately represent integer values up to 2242^24 (approximately 16.7 million) without any loss of precision.

Preserving the floating point type in screen space is also useful if we need to perform further transformations within that space, and having access to floating-point accuracy would be helpful in that work.

In such cases, we’d also want to hold off on rounding our values to integers until we complete those transformations. We don’t want to round our values multiple times, as each rounding has a performance cost and may also represent a loss of data/accuracy.

Summary

Working with continuous values in games requires specific techniques to ensure precision and correctness. Throughout this lesson, we've seen how to properly compare floating point values, understand their limitations, and transform them accurately to screen space coordinates. Here are the key points:

  • Floating-point numbers require special considerations when comparing values or converting to integer pixels.
  • We should be extremely cautious with using the == and != operators with floating point numbers. It’s usually the case that we should consider floating point values to be equivalent if their difference is very small, but not necessarily 00.
  • Converting floating point numbers to integers directly often results in loss of accuracy, as that action just discards the floating point component, effectively always rounding down. We typically want to round them to the nearest integer instead.
Free and Unlimited Access

Professional C++

Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Ryan McCombe
Ryan McCombe
Updated
Lesson Contents

Discrete and Continuous Values

Learn how to handle floating point precision issues when programming game physics and transformations between coordinate spaces.

sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Get Started for Free
Spaces and Transformations
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

This course includes:

  • 96 Lessons
  • 92% Positive Reviews
  • Regularly Updated
  • Help and FAQs
Free and Unlimited Access

Professional C++

Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Contact|Privacy Policy|Terms of Use
Copyright © 2025 - All Rights Reserved