Create a Roblox voting system
A Roblox voting system gives players the power to select what they want to happen next. Players can vote on anything you as the developer want to empower players to choose. Players can choose the next level to play, who should be the team leader, or which rewards should become available in the next release.
Player voting is also a great way to get feedback and keep players engaged. If you have your own player survey API you can also use the Roblox HttpService to get surveys and receive player feedback.
Different ways to show voting options
For players to submit their vote we can use several methods. Players can walk onto a pad to select their desired choice or make a selection from a Gui screen.
Take a player poll by touching world parts
With just Roblox events and functions we can use the Touched event for each voting pad. We can also create a voting controller object using the Knit framework.
Using Touched event only
local votingPad = script.Parent
local padTouched = false
local function onVotingPadTouched(part)
if not padTouched then
padTouched = true
-- validate part is a player
-- store player’s vote with VotingService
wait(1) -- give player one second to get off pad
padTouched = false
end
end
votingPad.Touched:Connect(function(part)
onVotingPadTouched(part)
end)
Using a Knit framework controller
local VotingController = Knit.CreateController { Name = "VotingController" }
function VotingController:SubmitVote()
Knit.GetService(“VotingService”):SubmitVote(self.PlayerVote):andThen(function()
-- close voting UI
end)
end
We’ll cover how the voting service works a bit later.
Let players choose with a GUI
To set up our GUI we can use the built-in Roblox APIs or use the Roact framework. The Roact framework is best for games with a lot of UI components. Built-in APIs are good for simple UIs and games with a very minimal need for on-screen UI elements.
Build voting GUI using a Screen Gui
We’ll use a ScreenGui object to display the voting options to players. Each option gets placed into a grid layout with buttons to submit their choice. Under each choice, you can use a focus border, a radio button to indicate the player’s selection, or change the opacity of all unselected options.
Let’s create a LocalScript under the PlayerStarterScripts folder.
We’ll use constant values to define and create the grid for all choices available for an election. For each choice cell in our grid, we’ll use scaling values for responsive design.
local GuiService = game:GetService("GuiService")
local ContextActionService = game:GetService("ContextActionService")
-- use scaling values for cell dimensions
local CHOICE_CELL_WIDTH = 0.25
local CHOICE_CELL_X_MARGIN = 0.04
local CHOICE_CELL_HEIGHT = 0.25
local CHOICE_CELL_Y_MARGIN = 0.02
local CHOICE_COLUMNS = 2
local CHOICE_ROWS = 0
local UI_OPEN_POSITION = UDim2.new(0.05, 0, 0.05, 0)
local UI_CLOSED_POSITION = UDim2.new(0.05, 0, -.9, -36)
local CLOSE_VOTING_UI_BINDING = "CloseVotingUI"
local EXIT_VOTING_UI_BINDING = "ExitVotingUI"
local player = game.Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
Next, we’ll define our UI structure with ScreenGui as the parent and Frames to break up our layout into logical sections.
-- Create UI screen
local screenGui = Instance.new("ScreenGui", playerGui)
-- button to open voting UI
local votingButton = Instance.new("TextButton", screenGui)
votingButton.Name = "votingButton"
votingButton.Text = "Vote!"
votingButton.Size = UDim2.new(0, 300, 0, 75)
votingButton.Font = Enum.Font.SourceSans
votingButton.FontSize = Enum.FontSize.Size60
-- main Frame UI
local menuFrame = Instance.new("Frame", screenGui)
menuFrame.Size = UDim2.new(0.75, 0, 0.75, 0)
menuFrame.Position = UI_CLOSED_POSITION
menuFrame.BackgroundTransparency = 1
-- voiting choices frames
local choicesFrame = Instance.new("Frame", menuFrame)
choicesFrame.Name = "choicesFrame"
choicesFrame.Size = UDim2.new(1, 0, 1, 0)
choicesFrame.Position = UDim2.new(0, 0, 0, 0)
local choiceScrollFrame = Instance.new("ScrollingFrame", choicesFrame)
choiceScrollFrame.Size = UDim2.new(1, 0, 1, 0)
choiceScrollFrame.CanvasSize = UDim2.new(0.9, 0, 1.8, 0)
choiceScrollFrame.Position = UDim2.new(0.0, 0, 0.0, 0)
choiceScrollFrame.Selectable = false
choiceScrollFrame.ScrollBarThickness = 0
We’ll use the playerSelected variable to store the player’s choice until it’s ready to submit. We’ll also include a function to close our UI once everything is done or the player decides to cancel.
local playerSelected = 'none'
local function CloseVotingUI()
-- animate screen out of view
menuFrame:TweenPosition(UI_CLOSED_POSITION, Enum.EasingDirection.Out, Enum.EasingStyle.Quad, 0.25, false, function(status)
-- remove exit button binding once animation finishes
ContextActionService:UnbindAction(CLOSE_VOTING_UI_BINDING)
end)
end
Using the constant values, we’ll create each choice as an ImageButton for each row and column. This also includes the button activation function that you can customize. Possible options include just storing the player’s choice or completing the entire process in one step.
-- Create voting choice grid cells
for y = 0, CHOICE_ROWS do
for x = 0, CHOICE_COLUMNS do
local choiceCell = Instance.new("ImageButton", choiceScrollFrame)
choiceCell.Image = "rbxassetid://133293265"
choiceCell.Name = "choiceCell(" .. x .. "," .. y .. ")"
choiceCell.Size = UDim2.new(CHOICE_CELL_WIDTH, 0, CHOICE_CELL_HEIGHT, 0)
choiceCell.Position = UDim2.new(CHOICE_CELL_X_MARGIN + CHOICE_CELL_X_MARGIN * x + CHOICE_CELL_WIDTH * x, 0,
CHOICE_CELL_Y_MARGIN + CHOICE_CELL_Y_MARGIN * y + CHOICE_CELL_HEIGHT * y, 0)
-- choice selected function
choiceCell.Activated:Connect(function()
playerSelected = choiceCell.Name
print(playerSelected)
CloseVotingUI()
end)
end
end
Finally, we’ll use the openVotingUI function to animate the UI onto the player’s screen. Using ContextActionService, we can control which buttons close the UI when a player wants to cancel. In this example, we’re binding the B key button but this can work with any button from a keyboard or gamepad.
local function openVotingUI()
-- animate screen into view
menuFrame:TweenPosition(UI_OPEN_POSITION, Enum.EasingDirection.Out, Enum.EasingStyle.Quad, 0.25, false, function(status)
-- bind exit button once animation finishes
ContextActionService:BindAction(CLOSE_VOTING_UI_BINDING, function(actionName, inputState, inputObject)
if inputState == Enum.UserInputState.Begin then
CloseVotingUI()
end
end, false, Enum.KeyCode.B)
end)
end
votingButton.MouseButton1Click:Connect(openVotingUI)
Here’s the final result. Of course, you can take this layout and functionality to fit your needs.
Build voting GUI using Roact framework
We’ll start with the Gui service declarations.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Roact = require(ReplicatedStorage.Roact)
We’ll use the PlayerChoice variable to store the player’s choice in memory.
local PlayerChoice = nil
Using Roact Fragments, we’ll loop through all available choices to add to the grid layout. Fragments help us maintain element hierarchy and structure. For example, the UIGridLayout object expects its siblings to include all elements that it will constrain in its grid layout.
local function ChoiceList(props)
print(props.Choices)
local elements = {}
for _, choice in pairs(props.Choices) do
elements[choice.Name] = Roact.createElement("TextButton", {
Text = choice.Name,
[Roact.Event.Activated] = function()
print('selected '.. choice.Name)
PlayerChoice = choice
end
})
end
return Roact.createFragment(elements)
end
We’ll define a few choices for this example though these could come from a remote API or even the Roblox data store.
local VotingChoices = {
{
Name = "option #1"
},
{
Name = "option #2"
},
{
Name = "option #3"
},
{
Name = "option #4"
},
{
Name = "option #5"
}
}
Now, we’ll extend the Frame object to include variable bindings. These bindings allow us to control the properties of our elements such as the Frame’s visibility.
local MainFrame = Roact.Component:extend("Frame")
function MainFrame:init()
self.visibility, self.updateVisibility = Roact.createBinding(true)
end
function MainFrame:render()
return Roact.createElement("Frame", {
Position = UDim2.new(0, 10, 0, 10),
Size = UDim2.new(0.75, 0, 0.75, 0),
Visible = self.visibility
}, {
ChoicesFrame = Roact.createElement("Frame", {
Position = UDim2.new(0, 0, 0, 0),
Size = UDim2.new(1, 0, 0.75, 0)
}, {
Layout = Roact.createElement("UIGridLayout"),
Items = Roact.createElement(ChoiceList, { Choices = VotingChoices })
}),
SubmitButton = Roact.createElement("TextButton", {
Position = UDim2.new(0.45, 0, 0.75, 0),
Size = UDim2.new(0, 100, 0, 60),
Text = "Submit!",
[Roact.Event.Activated] = function()
print('submitting ' .. PlayerChoice.Name)
self.updateVisibility(false)
end
})
})
end
Finally, we’ll add the main container frame as a child to ScreenGui and use the mount function to render it on the player’s GUI.
local app = Roact.createElement("ScreenGui", {}, {
MainFrame = Roact.createElement(MainFrame)
})
Roact.mount(app, Players.LocalPlayer.PlayerGui)
Instead of displaying this immediately once a player joins, we can instead call the mount function using the ContextActionService or bind it to another button that’s always on the player’s screen.
You can adjust this layout and style to fit your needs.
While using Roact has a bit of a learning curve, it helps you build reusable UI components and separates them into logical units.
How to store and count voting results
For elections that span long periods of time such as a week or more, it makes sense to use a Roblox data store to collect player votes.
Check out my Roblox data store guide to learn more.
In our data store, we’ll use several key schemas. First, we’ll need one for each election to store possible choices and the final result.
Election key schema and data structure
election_{unique election id}
For each unique id, we can use the HttpService:GenerateGUID function.
{
ElectionId = 1234, -- unique election id
StartTimestamp = 1640666295, -- when election begins
EndTimestamp = 1643344695, -- when election ends and votes are tallied
Options = {}, -- array of options
Ballots = {}, -- all player submitted ballots
Result = nil – table of final tallied result
}
Each voting option uses this table structure.
{
OptionId = <someUniqueNumber>,
Title = “option title”
}
Ballots use this table structure that we’ll use to validate when counting votes.
{
ElectionId = 1234,
OptionId = 2345,
PlayerId = 3456,
SubmittedOn = 4567, -- timestamp when it was received
}
When a player submits their vote, we’ll use this key schema. Use the os.time() function to get the current server UNIX timestamp.
election_{unique election id}_player_{playerId}
-- player Ballot structure
{
PlayerBallotId = 2345, -- unique player ballot id
ElectionId = 1234, -- unique election id
Vote = {} -- copy of option table structure
}
Voting service example
local DataStoreService = game:GetService("DataStoreService")
-- Voting service constructor
local VotingService = {}
local function VotingService:new()
local o = {}
o.datastore = DataStoreService:GetDataStore("PlayerElections")
self.__index = self
return setmetatable(o, self)
end
local function VotingService:_GetElectionDataKey(electionId)
local dataKey = "election_" .. electionId
return dataKey
end
local function VotingService:_GetPlayerVoteDataKey(electionId, playerId)
local dataKey = "election_" .. electionId .. "_player_" .. playerId
return dataKey
end
local function VotingService:CreateElection(start, duration, options)
local election = {
ElectionId = HttpService:GenerateGUID(false),
StartTimestamp = start,
EndTimestamp = start + duration,
Options = options,
Ballots = {},
Result = nil
}
self.datastore:SetAsync(electionDataKey, election)
end
local function VotingService:GetElectionData(electionId)
return self.datastore:GetAsync(self:_GetElectionDataKey(electionId))
end
local function VotingService:SavePlayerVote(playerId, vote)
local electionData = self:GetElectionData(vote.ElectionId)
local dataKey = self:_GetPlayerVoteDataKey(vote.ElectionId, playerId)
electionData.Ballots[dataKey] = vote
local electionDataKey = self:_GetElectionDataKey(vote.ElectionId)
self.datastore:UpdateAsync(electionDataKey, electionData)
end
local function VotingService:RemovePlayerVote(playerId, vote)
local electionData = self:GetElectionData(vote.ElectionId)
local dataKey = self:_GetPlayerVoteDataKey(vote.ElectionId, playerId)
electionData.Ballots[dataKey] = nil
local electionDataKey = self:_GetElectionDataKey(vote.ElectionId)
self.datastore:UpdateAsync(electionDataKey, electionData)
end
When finishing an election by tallying all the ballots, it’s important to ensure that we only count valid ballots. We can validate ballots by confirming the ballot was submitted within the voting period and includes a valid option for the election.
local function VotingService:FinishElection(electionId)
local dataKey = self:_GetElectionDataKey(electionId)
local election = self.datastore:GetAsync(dataKey)
-- loop through all ballots
local validBallots = {}
local now = os.time()
-- validate each ballot was received during election period
for ballotKey, ballot in pairs(election.Ballots) do
if now >= election.StartTimestamp and now <= election.EndTimestamp then
validBallots[ballotKey] = ballot
end
end
-- validate ballot refers to an option in the election list
for ballotKey, ballot in pairs(validBallots) do
results[ballot.OptionId] = results[ballot.OptionId] + 1
end
-- update results
election.Result = results
self.datastore:UpdateAsync(electionDataKey, election)
end
Connect Voting service between client and server
By using the Knit framework we can easily connect client scripts with the voting service.
Check out my Roblox Frameworks guide to learn more.
Knit Voting Service example
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local VotingService = require(ReplicatedStorage:WaitForChild("VotingService"))
local KnitVotingService = Knit.CreateService { Name = "KnitVotingService", Client = {} }
function KnitVotingService:SavePlayerVote(player, vote)
VotingService:SavePlayerVote(player.UserId, vote)
end
function KnitVotingService:RemovePlayerVote(player, vote)
VotingService:RemovePlayerVote(player.UserId, vote)
end
function KnitVotingService:FinishElection(electionId)
VotingService:FinishElection(electionId)
end
-- periodically check if any elections are done
local lastChecked = os.time()
local finishElectionsInterval = 300 -- check every 5 minutes
while true do
-- retrieve elections and filter for any that are completed
local elections = VotingService:GetElections()
for id, election in pairs(elections) do
if election.EndTimestamp >= now and election.Result == nil then
-- process finished election results
VotingService:FinishElection(election.ElectionId)
end
end
end
We have this time interval between each election finalization to keep requests within the data store limits. Check out my Data Store guide to learn more about these limits.
Build a memory only Roblox Voting System Service
For short elections such as the next level to play in the next round, we can store player votes in memory in a server script. These types of elections don’t need a data store since they only run for a few minutes each time.
We could get extra fancy and make a generic voting service that works for short and long-running elections. By using the strategy design pattern, the service will know where to obtain election data when it’s time to count all votes.
So we’d have a data store strategy and an in-memory strategy with the same functions in its interface.
In either case, we’ll use the same table structures and use the same logic when processing final results.
We’ll also need to create a timer to keep the game moving forward. If a player doesn’t make a choice before the time is up, the majority still wins. In the case that no one votes, we can either give all players another period to choose or let the computer decide with a random choice.
Check out my Roblox timers guide to learn more.
What’s next
You now have a grasp of how to build a Roblox voting system service that allows players to vote. By using these patterns and concepts you can make similar Roblox game services such as this voting system.
Please consider joining my email newsletter for future guides and updates!
Thank you for reading, and stay curious.