This example demonstrates a reusable infinite scrolling system for Defold. The setup works with any type of game object and allows you to build multiple scrolling layers, making it useful for parallax backgrounds, decorations, or endlessly moving scenery.
The project uses assets from the Shape Characters by Kenney.
The scene is built from several independent scrolling layers.
main.collection
├── floor.collection
├── background.collection
├── middle.collection
└── foreground.collection
Each layer is a collection containing:
a root game object with scroll_controller.script
one or more scrolling game objects
scroll_item.script attached to every scrolling object
Each layer can have its own speed, direction and scrolling bounds.
Every scrolling object contains a small helper script.
During initialization it registers itself with the layer controller by sending its game object id.
function init(self)
msg.post("root#scroll_controller", "prop:register", {
id = go.get_id()
})
end
The controller collects all registered objects before starting the scrolling logic.
Each scrolling layer can be configured using script properties.
go.property("SPEED", 90)
go.property("DIRECTION", -1)
go.property("LEFT_BOUND", -1000)
go.property("RIGHT_BOUND", 1000)
go.property("COUNT_OBJECTS", 1)
| Property | Description | | ——– | ——- | | SPEED | Scrolling speed in pixels per second. | | DIRECTION | Scroll direction (-1 for left, 1 for right). | | LEFT_BOUND | Position where objects wrap when scrolling left. | | RIGHT_BOUND | Position where objects wrap when scrolling right. | | COUNT_OBJECTS | Number of scrolling objects expected in the layer. |
Different collection instances can override these values to create parallax effects without changing any code.
Once all objects have registered, the controller:
Instead of using a fixed spacing value, the controller stores the original distance between neighbouring objects. This means the objects can be placed freely in the editor while preserving their layout during scrolling.
Every frame, all objects move by the same amount.
When the leftmost object reaches the left boundary, it is moved to the right side of the layer using the previously calculated spacing. The same logic can also be applied when scrolling in the opposite direction.
Because the original spacing is preserved, the scrolling loop appears seamless even when objects are unevenly distributed.
The system is designed to be reusable.
You can:
No changes to the scripts are required when creating additional layers.
By separating object registration from movement logic, the same scrolling system can be reused across many collections. Each layer manages its own objects independently, making it easy to build infinite scrolling scenes and parallax backgrounds while keeping the project structure simple
scroll_item.script
function init(self)
msg.post("root#scroll_controller", "prop:register", { id = go.get_id() })
endscroll_controller.script
go.property("SPEED", 90)
go.property("DIRECTION", -1)
go.property("LEFT_BOUND", -1000)
go.property("RIGHT_BOUND", 1000)
go.property("COUNT_OBJECTS", 1)
local function register_objects(self)
for i, obj in ipairs(self.OBJECTS) do
obj.pos = go.get_position(obj.id)
end
table.sort(self.OBJECTS, function (a, b)
return a.pos.x < b.pos.x
end)
for i = 1, #self.OBJECTS - 1 do
local current = self.OBJECTS[i]
local next = self.OBJECTS[i + 1]
current.next_offset = next.pos.x - current.pos.x
end
self.OBJECTS[#self.OBJECTS].next_offset =
(self.RIGHT_BOUND - self.OBJECTS[#self.OBJECTS].pos.x)
+
(self.OBJECTS[1].pos.x - self.LEFT_BOUND)
end
function init(self)
self.OBJECTS = {}
end
function update(self, dt)
local dx = self.SPEED * self.DIRECTION * dt
for _, obj in ipairs(self.OBJECTS) do
obj.pos.x = obj.pos.x + dx
go.set_position(obj.pos, obj.id)
end
if self.DIRECTION == -1 then
local leftmost = self.OBJECTS[1]
if leftmost.pos.x < self.LEFT_BOUND then
local rightmost = self.OBJECTS[#self.OBJECTS]
leftmost.pos.x = rightmost.pos.x + rightmost.next_offset
go.set_position(leftmost.pos, leftmost.id)
table.sort(self.OBJECTS, function(a,b)
return a.pos.x < b.pos.x
end)
end
return
else
local rightmost = self.OBJECTS[#self.OBJECTS]
if rightmost.pos.x > self.RIGHT_BOUND then
local leftmost = self.OBJECTS[1]
rightmost.pos.x = leftmost.pos.x -- - self.SPACING
table.sort(self.OBJECTS, function (a, b)
return a.pos.x < b.pos.x
end)
end
return
end
end
function on_message(self, message_id, message)
if message_id == hash("prop:register") then
table.insert(self.OBJECTS, { id = message.id })
if #self.OBJECTS == self.COUNT_OBJECTS then
register_objects(self)
end
end
end