Component Based Scripting?

So…I’m doing some prototyping, and I keep wanting to change the movement behavior of one of the creeps in my game so I can test things.

I most other engines I’d have some kind of “movement” script that managed the game objects motion and I’d just remove it, and add a different one (say swapping a_star_movement_behavior for forwards_movement_behavior).

I know I can add multiple scripts to an object, and can do this (that’s fine). I’m curious about whether this would be considered “idiomatic” though. I’m also curious on the effects on the msg pump, cos it means I’m going to be receiving messages in more scripts (say 3-4 on a single go instead of 1). I also know I can do this with lua modules, but directly getting the lifecycle function callbacks directly in your component has some pretty sweet benefits.

So…is this idea idiomatic? Am I going to…hang myself in the long run? Do I need to worry about message passing (I remember reading there is an effective per frame max?)

Normally I would just run off and do it and see, but some other folks I’ve got into game development with Defold had similair questions and I thought it might be beneficial to have the answer written down somewhere.

Not entirely to the point in asking your question, but I know there’s a limit on how many game objects with script components attached to them you can have. I think it’s 1024.

Now, I don’t know if this means max 512 g.o’s with 2 script components or how that works.

I’m curious about the answer to your question as well. My guess though is that it’s whatever floats your boat.

If it’s game objects that have a large amount of instances then I’m guessing the recommendation is to keep it to only one script and divide things into Lua modules if you want to swap behaviours.

This will only happen if you have the on_message function in your script. If you remove it you’ll not get sent any messages. This is an important thing to keep in mind. If you don’t need a lifecycle function then remove it. This is especially important for update() but also on_message() and on_input() (in the case where the game object has acquired input focus)

Yes, getting the lifecycle functions and the self reference is very convenient. You could forward these functions and the self ref to your modules though.

Maybe. The engine will try to empty message queue several times per frame (check manual for lifecycle and message pump). BUT you should not replace function calls with message passing and vice versa. If you need to send a lot of messages every frame just to get your game working then that could be an indication of a design issue.

It’s a really good question and the answer depends a lot on the kind of project you’re creating. How many scripts are we talking about? How many messages?

Personally I prefer to use fewer scripts and break out larger pieces of logic that could be reused from several scripts into Lua modules. I’ve tried the approach with multiple scripts per enemy/entity/thing in a platformer game and I weren’t really happy with the result. Too many scripts and quite a few messages between scripts.

Good to know, thank you.

So…I was more thinking about something like this:

Lets say you have a health_system script, it has on_message that cares about a take_damage message.
Lets say you also have a move_system script, is has an on_message that cares about a set_target message.

I don’t intend to call from one to the other using messages, but if I have both scripts on the same object, both scripts will recieve each message right? That’s the issue I’m curious about.

That’s where I’m heading atm, I was just curious if that was the best method. Which it sounds like it is.

Remember that you can call functions from other scripts directly, like this:

local other_script_context = get_other_script_context_somehow_probably_from_shared_module()
__dm_script_instance__ = other_script_context

other_script_context.some_function()
other_script_context.some_variable = ...

__dm_script_instance__ = self

in ‘other script’

local function some_function()
 ...
end

function init(self)
    store_script_context_in_shared_module(self)
    self.some_function = some_function
end

Often there is even no needs to change context. Just call functions.

Keep in mind that this is undocumented and could potentially change.

1 Like

Yes, both scripts will get the message, unless it’s sent specifically to a certain script component and not the game object.

Let me just drop an interesting alternative of doing this. I also love this way of “composing” my gameobjects. Here is my solution using lua modules as alternative giving you more flexibility, more efficiency, less messages and more access between the “scripts”.

--[[
Instead of hooking a lot of scripts on a gameobject this module helps you use lua modules instead with very similar behaviour. 
Script_modules are specific modules for individual gameobjects and will have all script functions called.
Benefit except from optimisation (as several scripts are quite heavy) is also to ensure run order and sharing of properties.
The order the modules are registered in, in that order will all functions (eg update, on_message) run.

The modules will share the same SELF with the go_script
mself is a seperate instance module-context that will not collide with other modules or script self.
All script functions can be passed into the modules. They have the same signature with an additional 'mself' in the end of each function (see example)

Register with full paths: 
{	
	"game.units.tank.turret", 
	"game.units.tank.movement"  
},
Also modules needs to be required somehow to be collected by the defold build (preferable in same module that holds the paths).

	EG MAIN GAMEOBJECT SCRIPT:
		local script_modules = require "lux.utils.script_modules"

		-- these are the modules that will be used "just as scripts"
		local m_table = {
			"game.units.movement",
			"game.units.inventory",
			"game.units.health",
			"game.units.ai",
		}
		
		function init(self)
			script_modules.register_modules(self, m_table)
			script_modules.init(self)
		end

		function update(self, dt)
			script_modules.update(self, dt)
		end

		function on_message(self, message_id, message, sender)
			script_modules.on_message(self, message_id, message, sender)
		end

		function final(self)
			script_modules.final(self)
		end

	
	EG MODULE:
		local M = {}

		function M.init(self, mself)
			mself.offset = vmath.vector4(0,0,0,0) -- private for this module and instance
			self.name = "Andy" -- will be shared with all modules and main script.
		end

		function M.update(self, dt, mself)
			mself.offset.x = (mself.offset.x - dt ) -- will only affect this instance and module.
		end

		function M.on_message(self, message_id, message, sender, mself)
			if message_id == hash("bullet_hit") then
				-- all modules with a function named "take_damage" will be called (in order) and sent how much damage (hit_points)
				script_modules.call_custom(self, "take_damage", nil, message.hit_points) 
			end
		end

		function another_custom_fn(self, mself, val_1, val_2)
			-- this function can be called from any other module or main script.
		end

		function M.final(self, mself)
			-- end it on module level
		end

		return M
--]]




local M = {}

-- needs to be done before anything else
-- can be called several times with different modules. This will then erase the last ones.
function M.register_modules(self, module_paths) 
	self.script_modules = {
		mselfs = {},
		active = {},
		inactive = {},
	}
	if module_paths then
		for i,path in ipairs(module_paths) do
			local m = require(path)
			table.insert(self.script_modules.active, m) 
			self.script_modules.mselfs[m]={}
		end
	end
end

--[[ 
	Call a custom function in all modules containing the function name. 
	If module_index is provided it will only call function in that specific module
	Each custom function must have self and mself as its 2 first arguments and then any argument passed into args (...)
--]]
function M.call_custom(self, fn_name, module_index, ...)
	local s = self.script_modules
	if module_index then
		local mod = s.active[module_index]
		if mod and mod[fn_name] then
			mod[fn_name](self, s.mselfs[mod], unpack({...}))
		end
	else
		for i,mod in ipairs(s.active) do
			if mod[fn_name] then
				mod[fn_name](self, s.mselfs[mod], unpack({...}))
			end
		end
	end
end

function M.init(self)
	local s = self.script_modules
	for i,mod in ipairs(s.active) do
		if mod.init then
			mod.init(self, s.mselfs[mod])
		end
	end
end

function M.update(self, dt)
	local s = self.script_modules
	for i,mod in ipairs(s.active) do
		if mod.update then
			mod.update(self, dt, s.mselfs[mod])
		end
	end
end

function M.on_message(self, message_id, message, sender)
	local s = self.script_modules
	for i,mod in ipairs(s.active) do
		if mod.on_message then
			mod.on_message(self, message_id, message, sender, s.mselfs[mod])
		end
	end
end

function M.on_input(self, action_id, action)
	local s = self.script_modules
	for i,mod in ipairs(s.active) do
		if mod.on_input then
			mod.on_input(self, action_id, action, s.mselfs[mod])
		end
	end
end

function M.final(self, mself)
	local s = self.script_modules
	for i,mod in ipairs(s.active) do
		if mod.final then
			mod.final(self, s.mselfs[mod])
		end
	end
end

return M

6 Likes

@andreas.strangequest this seems like a great way to go about modules in a game. I’d be interested in knowing if this is still recommended given it’s been 3 years since this was posted?

Defold very rarely changes fundamentally in the way things work so in general I’d say that a recommendation made a few years back still holds true.

The solution suggested by @andreas.strangequest adds some overhead to each lifecycle function but is otherwise a pretty nice solution to modular scripts.

3 Likes