Infinite Scroll Animation

Learn how to make infinite scroll for all objects on your level with only two scripts

Project files

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.

Project Structure

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.

Registering Objects

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.

Controller Properties

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.

How It Works

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.

Customizing

The system is designed to be reusable.

You can:

No changes to the scripts are required when creating additional layers.

Summary

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

Scripts

scroll_item.script

function init(self)
	msg.post("root#scroll_controller", "prop:register", { id = go.get_id() })
end

scroll_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