Storyline/Tasks handling

I’m doing my first “storyline/tasks” system. I don’t know how to call it, but it would work like this:

  • After players joined and someone pressed ready, everyone will be teleported to special spawns.
  • At this point “storyline” starts, where you are given tasks to do (in order).
  • Some tasks would be randomized, I mean picked from pool of random tasks.
  • Trying to trigger next task before doing previous one won’t do anything.

Easiest way to explain it: Enter main area → Look at the desk → Pick up the keys → Leave the room
if keys not on the desk: Enter main area → Loot at the desk (no keys) → Look for the keys → Leave the room

THE PROBLEM IS I have no idea how to start with such system. Can’t wrap my head around this.
Was reading multiple articles about storyline and most of the talk about using Modules and RemoteEvents, which I’m already using…

So my question is: How do I create storyline system where certain actions and tasks player do will progress the game for everyone?

I already did some scripting, mainly using dicionaries and objects but it feels “wrong” in some way:

States dictionary

local TweenService = game:GetService('TweenService')
local CollectionService = game:GetService('CollectionService')

local EnterMainAreaPrompt = CollectionService:GetTagged("EnterMainAreaPrompt")[1]
local StorageArea = CollectionService:GetTagged("StorageArea")[1]

local States = {
	{
		Name = "EnterMainArea",
		Data = {
			Task        = "Enter the door",
			Description = "Find out what wait for you...",
			Event = {
				Connect = EnterMainAreaPrompt.Triggered,
				To = "MainAreaEntered"
			},
			Trigger = function(controller)
				print("Task:", controller.State.task, controller.State.Description)
			end,
			onEvent = function(controller, Player)
				local Door = workspace.Door

				local tweenInfo = TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.Out)

				local Tween = TweenService:Create(Door, tweenInfo, {
					Position = Vector3.new(Door.Position.X, Door.Position.Y + 7, Door.Position.Z)
				})
				
				Tween:Play()
				controller:NextState()
			end
		},
	},
	{
		Name = "GoToStorageArea",
		Data = {
			Task        = "Move to storage area",
			Description = "",
			Event = {
				Connect = StorageArea.Touched,
				To = "StorageAreaEntered"
			},
			Trigger = function(controller)
				print("Task:", controller.State.task, controller.State.Description)
			end,
			onEvent = function(controller, Player)
				controller:NextState()
			end,
		},
	},
}

return States

MissionController

local CollectionService = game:GetService('CollectionService')
local ReplicatedStorage = game:GetService('ReplicatedStorage')
local Players           = game:GetService('Players')

local States = require(script.States)

local ServerEvents = ReplicatedStorage.Events.Server
local ClientEvents = ReplicatedStorage.Events.Client
local StatesEvents = ReplicatedStorage.Events.States

local Chairs = CollectionService:GetTagged("MissionSpawn")

local MissionController = {}
MissionController.State = nil
MissionController.StateIndex = 1
MissionController._currentConnection = nil

function MissionController:Init()
	
	ServerEvents.startMission.OnServerEvent:Connect(function(Player)
		ClientEvents.prepPlayers:FireAllClients()

		for i, player in ipairs(Players:GetPlayers()) do
			local HumanoidRootPart = player.Character:WaitForChild('HumanoidRootPart')
			HumanoidRootPart.CFrame = Chairs[i].Seat.CFrame
			print(Chairs)
		end
		
		self.StateIndex = 1
		self:Start()
		
	end)
	
	return true
end
function MissionController:Start()
	self:ChangeState(States[self.StateIndex])
end
function MissionController:ChangeState(state)
	self.State = state
	
	if self._currentConnection then
		self._currentConnection:Disconnect()
	end
	
	if state and state.Trigger then
		state.Trigger(self)
	end
	
	if state.Data and state.Data.Event and state.Data.Event.Connect then
		self._currentConnection = state.Data.Event.Connect:Connect(function(...)
			
			if state.Data.Event.To then
				local eventObj = StatesEvents:FindFirstChild(state.Data.Event.To)
				if eventObj then
					eventObj:FireAllClients()
				else
					warn("No event found for", state.event)
				end
			end
			
			if state.Data.onEvent then
				state.Data.onEvent(self, ...)
			end
		end)
	end
end
function MissionController:NextState()
	self.StateIndex += 1
	
	if self.StateIndex <= #States then
		self:ChangeState(States[self.StateIndex])
	else
		print("Mission finished!")
	end
end

return MissionController

[IMPORTANT NOTE]: return true in my controller means the :Init() was successful

2 Likes

I apologize, but usually such games are made for one person. If you are not going to expand the game to multiplayer and your game is single-player, you do not need to connect anything in it to the server (only some things like datastore and which have no point in defending because game is single player)

1 Like

The whole point is to do it multiplayer. Should be more clear with that. Its a 4 player horror game and progress should be shared. Like, one person pressed the button and it progresses their whole team, letting them go to another room or something idk.

3 Likes

I recommend creating a “TaskStart” and a “TaskEnd” event.
The TaskStart event fires when players enter the main area, for example, and the TaskEnd event fires when a player picks up a key, for example. When the TaskEnd event fires, players are teleported to another location, for example, or a new task begins.

2 Likes

Thought about something simillar. I’m more worried about my structure as a whole, because I don’t know if dicionaries will do their job right. My main thought when doing script above was to connect an event from States.Data.Event.Connect and waiting till it’s triggered. When connected, Trigger will be triggered and after player, for example, pressed the prompt, onEvent will trigger chaining everything together with controller:NextState(). Doing it with :TaskStart() would be simillar??? but how would I listen for task to be done to :TaskEnd(). I am very confused :upside_down_face:

Is it good idea to store whole events in my dicionaries? What I mean is something like this (look Event):

{
	Name = "EnterMainArea",
	Data = {
		Task        = "Enter the door",
		Description = "Find out what wait for you...",
		Event = {
			Connect = EnterMainAreaPrompt.Triggered:Connect(function(Player)
                -- here goes tween or something, and :EndTask/:NextTask runs and gets us another task
				print("CONNECTED EVENT")
			end),
			To = "MainAreaEntered"
		},
		Trigger = function(controller)
			print("Task:", controller.State.task, controller.State.Description)
		end,
		onEvent = function(controller, Player)
			local Door = workspace.Door

			local tweenInfo = TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.Out)

			local Tween = TweenService:Create(Door, tweenInfo, {
				Position = Vector3.new(Door.Position.X, Door.Position.Y + 7, Door.Position.Z)
			})
			
			Tween:Play()
			controller:NextState()
		end
	},
},

What you could do is to have a callback in each “task” which returns true or false (or stops yielding) whenever the player completes it.

Then, store each task in a table. Each time a player completes said task, pop the task from the table and yield on the next one.

I do something similar to this when making a tutorial.

Could be something like:

local tasks: {Task} = {}

while #tasks > 0 do
    local task = table.remove(tasks)
    task.Completed:Wait()
end

Do you have an UI for your tasks?

If so, you can do it this way: save all your tasks in a module script, in the order in which players should complete them, and give them a simple, descriptive name.

You can then use a “System” script to control when which events are triggered.

Then you add another script to your UI, where you use the module script to indicate which task the players should perform when the TaskStart event is triggered.

If you don’t have a UI to display the tasks, you’ll obviously have to modify it a bit. But that’s how I would do it. There are other ways, though.

Here is a simple, clear explanation:

Players enter the Main Area → TaskStart fires

TaskStart fires → Task displays on Task UI

Player picks up a key → TaskEnd fires

TaskEnd fires → TaskStart fires(for the next Task)

hope this helps! :slight_smile:

2 Likes

I personally would use a module script to store the data of each task, and have each task lead into the next possible ones.

StoryData (modules script)

local storyData = {}

local storyFunctions = require(PathToStoryFunctions)

storyData.Chapter1 = {
    ["FindKeys"] = {
        DisplayText = "Find the missing keys.",
        Function = storyFunctions.FindPath,
        NextPossibleTasks = {"GoToDesk",  "GoToCouch"}
    },

    ["GoToDesk"] = {
        DisplayText = "Maybe they're on the desk.",
        Function = storyFunctions.CheckDesk,
        NextPossibleTasks = {"LeaveRoom"}
    },

    ["GoToCouch"] = {
        DisplayText = "Check under the couch cushions.",
        Function = storyFunctions.CheckCouch,
        NextPossibleTasks = {"LeaveRoom"}
    },

    ["LeaveRoom"] = {
        DisplayText = "Leave the room",
        Function = storyFunctions.LeaveRoom,
        NextPossibleTasks = {"NextTask"}
    }
}

return storyData

Then you’d create an external system that’d run from the first task, and use the functions to check when a task is completed. The functions will also allow you to handle remotes seperately, which’ll simplify them for you.

I understand your idea, and no, I don’t have any UI for displaying my tasks for now. My main goal for now is to have working chain of events happening. Worst part about his is handling those events. One of the problems was all events connecting at the same time, so order had no meaning. Then I wanted to connect current task event and disconnect previous one if existed. Both ways feel wierd in a way locking me from scaling this in a future, at least I think so.

And I would run a function for current task? So each function would hold, for example, DoorButton.Triggered and entire code for this, with tweens etc? What about NextPossibleTask? If I want some of the task to be randomly chosen, I would get random name from that array and go to that index, right?

I went with your idea and it looks something like this:

TasksManager

local ReplicatedStorage = game:GetService('ReplicatedStorage')
local CollectionService = game:GetService('CollectionService')
local TweenService      = game:GetService('TweenService')

local EnterMainAreaPrompt = CollectionService:GetTagged("EnterMainAreaPrompt")[1]
local StorageArea         = CollectionService:GetTagged("StorageArea")[1]

local TasksManager = {}
function TasksManager.EnterMainArea(MissionController)
	return EnterMainAreaPrompt.Triggered:Connect(function()
		local Door = workspace.Door
		local tweenInfo = TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.Out)
		local Tween = TweenService:Create(Door, tweenInfo, {
			Position = Vector3.new(Door.Position.X, Door.Position.Y + 7, Door.Position.Z)
		})
		
		Tween:Play()
		MissionController:NextTask()
	end)
end
function TasksManager.GoToStorageArea(MissionController)
	print("WORKS")
end

return TasksManager

Tasks

local TweenService = game:GetService('TweenService')
local CollectionService = game:GetService('CollectionService')

local TasksManager = require(script.Parent)

local Tasks = {
	["EnterMainArea"] = {
		DisplayText     = "Enter the door",
		DescriptionText = "Find out what wait for you...",
		Trigger = TasksManager.EnterMainArea,
		NextPossibleTasks = { "GoToStorageArea" }
	},
	["GoToStorageArea"] = {
		DisplayText     = "Go to storage area",
		DescriptionText = "",
		Trigger = TasksManager.GoToStorageArea,
		NextPossibleTasks = {"PickUpGlassBall"}
	},
	["PickUpGlassBall"] = {
		DisplayText     = "Pick up glass ball",
		DescriptionText = "It's probably hidden somewhere...",
		Trigger = TasksManager.PickUpGlassBall,
		NextPossibleTasks = nil
	}
}

return Tasks

MissionController

local CollectionService = game:GetService('CollectionService')
local ReplicatedStorage = game:GetService('ReplicatedStorage')
local Players           = game:GetService('Players')

local Tasks = require(script.TasksManager.Tasks)

local ServerEvents = ReplicatedStorage.Events.Server
local ClientEvents = ReplicatedStorage.Events.Client
local StatesEvents = ReplicatedStorage.Events.States

local Chairs = CollectionService:GetTagged("MissionSpawn")

local MissionController = {}
MissionController.TaskIndex = 1
MissionController._currentTask = nil
MissionController._currentConnection = nil

function MissionController:Init()
	
	ServerEvents.startMission.OnServerEvent:Connect(function(Player)
		ClientEvents.prepPlayers:FireAllClients()

		for i, player in ipairs(Players:GetPlayers()) do
			local HumanoidRootPart = player.Character:WaitForChild('HumanoidRootPart')
			HumanoidRootPart.CFrame = Chairs[i].Seat.CFrame
			print(Chairs)
		end
		
		self:TaskStart()
		
	end)
	
	return true
end

function MissionController:TaskStart(taskName)
	local getTask
	if taskName == nil then
		getTask = Tasks["EnterMainArea"]
	else
		for _, taskObj in pairs(Tasks) do
			if taskObj.Name == taskName then
				getTask = taskObj
			end
		end
	end
	self:ChangeTask(getTask)
end
function MissionController:TaskEnd()
	-- wip
end
function MissionController:ChangeTask(taskObj)
	self._currentTask = taskObj

	if self._currentConnection then
		self._currentConnection:Disconnect()
		self._currentConnection = nil
	end
	
	print(self._currentTask.DisplayText)
	
	if taskObj.Trigger then
		self._currentConnection = taskObj.Trigger(self)
	end
end
function MissionController:NextTask(taskName)
	local possibleTasks = self._currentTask.NextPossibleTasks
	local randomTask = math.random(1, #possibleTasks)
	self:ChangeTask(Tasks[possibleTasks[randomTask]])
end

return MissionController

Everything works fine, but is it really necessary to do function for each of the task? What I mean is, if you look at the TasksManager, I will need to declare my connection, then what it does, then go next task or something else, then save current task so later I can disconnect it… and do that for every function manually. I would love to have more dynamic system where it automaticly does that connection and disconnections and :NextTask handling. Thats why I tried to store functions inside objects to just call them when specific event was connected. Hope this makes sense, because I’m replying in a rush right now! But lovely idea, it opened my eyes a bit more!

TL;DR: Everything works fine, but I’m annoyed it’s not as dynamic as I imagine it to be. I’m worried about scalability

EDIT: Got rid of the repeating connection thingy. Now I just return the whole event and save it in MissionController in _currentConnection

1 Like

Woke up with fresh mind and looked at my code. Doing it with connections is fine, but had an idea of a task where player has to wait for some reason, like untill doors open or untill electricity turns back on. In situation like this I would do boolean value as, for example, attribute saved somewhere and listen for .AttributeChanged?

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.