Hello everyone!
In this tutorial, I want to introduce you to an important way of thinking in programming called Data-Oriented Design (DOD) and explain some fundamental concepts such as AoS vs SoA, FIFO/LIFO in a simple and beginner-friendly way.
Most Roblox developers learn programming using Object-Oriented Programming (OOP) — classes, objects, and methods. OOP is very useful and convenient, especially for UI, tools, and general gameplay logic.
However, when it comes to performance-critical systems (signals, combat systems, ECS, animations, networking, or large update loops), OOP can become inefficient in Roblox because it relies heavily on tables, metatables, and many small objects.
This is where Data-Oriented Design comes in.
Instead of focusing on objects, DOD focuses on data and how it is stored and processed in memory.
The main idea is simple:
Don’t ask “What does this object do?”
Ask “What data do I need to process as fast as possible?”
In this tutorial, we will cover:
- What Data-Oriented Design (DOD) is and how it differs from OOP
- The difference between Array of Structures (AoS) and Structure of Arrays (SoA)
- What FIFO and LIFO mean and where they are used
- Where these concepts can be useful in Roblox projects
This tutorial is not about replacing OOP everywhere.
OOP is still great for many parts of a game. The goal is to show you another tool and another way of thinking that can help you write faster and more efficient code when performance really matters.
If you are curious about how to make your systems faster and want to understand how professional game engines think about data, then this tutorial is for you. ![]()
Let’s start by comparing Object-Oriented Programming and Data-Oriented Design.
Before we talk about AoS, SoA, and Ring Buffers, we need to understand the difference between two ways of thinking about code:
Object-Oriented Programming (OOP) and Data-Oriented Design (DOD).
These are not enemies. They are just different tools for different problems.
What is OOP?
In Object-Oriented Programming, we organize our code around objects.
Each object contains:
- data (fields / properties)
- behavior (methods / functions)
Example (OOP-style):
local SomeObject = {}
SomeObject.__index = SomeObject
function SomeObject.new(x, y, hp)
return setmetatable({
X = x,
Y = y,
HP = hp
}, SomeObject)
end
function SomeObjectr:Move(dx, dy)
self.X += dx
self.Y += dy
end
function SomeObject:TakeDamage(amount)
self.HP -= amount
end
Here, each SomeObject is its own table with its own data and methods.
This is very easy to read and very intuitive.
OOP is great for:
- UI systems
- tools and editors
- game logic
- small to medium systems
- code readability
What is DOD?
In Data-Oriented Design, we organize our code around data, not objects.
Instead of having many small objects, we store the same type of data in arrays and process them in simple loops.
Example (DOD-style):
local X = {}
local Y = {}
local HP = {}
local function MoveEntity(i, dx, dy)
X[i] += dx
Y[i] += dy
end
local function DamageEntity(i, amount)
HP[i] -= amount
end
Here, an “entity” is just an index i that refers to data in multiple arrays.
There is no SomeObject object - only data.
DOD is great for:
- large numbers of entities
- update loops
- combat systems
- ECS
- signals and events
- performance-critical code
Main Difference
The main difference is what you focus on first:
| OOP | DOD |
|---|---|
| Objects | Data |
| Methods | Loops |
| Many small tables | Few big arrays |
| Easy to read | Easy to optimize |
| Flexible | Fast |
OOP asks:
“What can this object do?”
DOD asks:
“What data do I need to process quickly?”
A Simple Comparison
Imagine you want to move 1000 objects.
OOP-style:
for _, object in ipairs(object) do
object:Move(1, 0)
end
Each loop:
- accesses a table
- calls a method
- looks up fields
DOD-style:
for i = 1, count do
X[i] += 1
end
This loop:
- works on raw arrays
- has fewer table lookups
- is easier for the engine to optimize
This is why DOD is often faster in hot loops.
This is very impotant section, dont skip any word.
What is a memory layout?
Memory layout - How stored our data in RAM and how easily CPU getting it.
More efficient memory layout = more easier to CPU.
There are two common ways to organize data are:
- AoS - Array of Structures
- SoA - Struct of Arrays
2.1 What is AoS (Array of Structures)
AoS means that each entity is stored as a single table that contains all its data.
Example (AoS style):
local Entities = {}
Entities[1] = { X = 0, Y = 0, HP = 100 }
Entities[2] = { X = 10, Y = 5, HP = 80 }
Entities[3] = { X = -3, Y = 2, HP = 120 }
Each element in the array is a structure (table) with multiple fields.
This is very similar to how OOP objects are usually stored.
Pros of AoS:
- Easy to read
- Easy to debug
- Natural for OOP thinking
- Good for small systems
Cons of AoS:
- Many table lookups (
entity.X,entity.Y,entity.HP) - Poor cache locality
- Slower when processing large amounts of data
- More memory overhead
2.2 What is SoA (Structure of Arrays)
SoA means that each type of data is stored in its own array.
Example (SoA style):
local X = {}
local Y = {}
local HP = {}
X[1], Y[1], HP[1] = 0, 0, 100
X[2], Y[2], HP[2] = 10, 5, 80
X[3], Y[3], HP[3] = -3, 2, 120
Here, the entity is just an index (1, 2, 3) across multiple arrays.
Pros of SoA:
- Fewer table lookups
- Better cache locality
- Faster in loops
- Easier to optimize
- Lower memory overhead
Cons of SoA:
- Harder to read
- Harder to debug
- More manual management
- Less intuitive at first
2.3 AoS vs SoA Comparison
| Feature | AoS | SoA |
|---|---|---|
| Readability | ||
| Performance | ||
| Memory usage | ||
| Cache locality | ||
| Debugging | ||
| Best for | Small systems, UI | Large systems, hot loops |
2.4 Practical Example: Moving Entities
Let’s compare how we move many entities in both approaches.
AoS version:
for i = 1, count do
local entity = Entities[i]
entity.X += 1
entity.Y += 1
end
Each iteration:
- accesses a table
- looks up fields
- updates values
SoA version:
for i = 1, count do
X[i] += 1
Y[i] += 1
end
Each iteration:
- accesses raw arrays
- has fewer lookups
- is more cache-friendly
This is why SoA is often much faster when count becomes large.
2.5 Why SoA fits Roblox well
Roblox’s Luau VM is optimized for:
- numeric indices
- arrays
- simple loops
- local variables
It is not optimized for:
- deep object hierarchies
- many small tables
- metatables in hot loops
SoA matches very well with how Luau executes code.
LIFO and FIFO is a ways how we can organize Queue in our games/libraries
FIFO (First In, First Out) - the first element added is the first one removed, Example: a line in a store
LIFO (Last in, First Out) - the last element added is, the firest one removed, example: Stack of plates
LIFO (Stack)
Simple stack example:
local stack = {}
-- push
stack[1] = "A"
stack[2] = "B"
stack[3] = "C"
-- pop
local last = table.remove(stack, #stack)
print("Removed:", last) -- log: C
Used for:
- undo systems
- temporary data
- recursion
- history
FIFO (Queue)
Naive queue (slow):
local queue = {}
local queue = {}
-- enqueue
queue[1] = "A"
queue[2] = "B"
queue[3] = "C"
-- dequeue
local first = table.remove(queue, 1)
print("Removed:", first)
print("Now queue:", table.concat(queue, ", "))
This is slow because all elements are shifted.
Used for:
- task queues
- events
- networking
- job systems
Summary
- LIFO = stack (last in, first out)
- FIFO = queue (first in, first out)
table.remove(queue, 1)is slow, but we solve this in next section
Post is too long, i will only make a links to a wikipedia
Ring buffer - Circular buffer - Wikipedia
Swap-Remove - O(1) method to make FIFO Queue fast, but it makes it Unordered
Batching - Batch processing - Wikipedia
Sentinels - Sentinel value - Wikipedia
How make Swap-Remove?
Example:
local queue = table.create(10)
for i = 1, 10 do
queue[i] = i
end
local swap_remove = function(index: number)
local last = #queue
queue[index] = queue[last]
queue[last] = nil
end
swap_remove(4)
-- what we get:
-- 1, 2, 3, 10, 5, 6, 7, 8, 9,
mini advices:
- use counters instead #array
- use
table.create(number, sentinel value) - use
shared refslike a Sentinels
Example of a shared ref:
local NULL = table.freeze({})
local array = table.create(10, NULL) -- all elements in array is a pointer to a shared ref
-- this is a very advanced sentinel!
-- we can check this
local isEmpty = array[1] == NULL-- true
array[1] = 123
local isEmpty = array[1] == NULL -- false
This is end of my Post. Happy coding! ![]()