Overview
Sal's Kewl Signage puts working electronic signs in your world. Fifteen types across three families, from a solar-powered VMS trailer at a roadworks site to a 24-meter overhead gantry to a drive-thru menu board, and every one of them shows whatever your scripts tell it to show — not a fixed set of baked messages.
Three things make it different from a prop pack with some text on it:
- Per-instance content. Two boards using the same prop show different things, because the lamps are drawn in world space rather than painted onto a shared texture. A street of message boards is a street of message boards, not fifteen copies of one.
- It ships with the hardware, as real props. Every type has a streamed model in the download — the solar trailer with its mast and wheels, the gantry posts and truss, the billboard monopole and catwalk — lit by the game, casting shadows, with collision. A trailer blocks a lane and traffic drives round it; whether it does is one config switch.
- A hard performance ceiling. Lamp geometry is computed once and replayed, boards off-camera cost nothing, and a per-frame triangle budget means a street full of signs degrades in detail instead of dropping your frame rate.
Server-authoritative, framework agnostic (qb / qbx / esx / standalone), no database, no dependencies.
Requirements
None. No framework, no database, no target resource, no ox_lib.
qb-core, qbx_core or es_extended are detected if present, to resolve jobs for the permission check. Without any of them the resource runs standalone and permissions fall back to ACE.
Installation
- Drop the folder into
resources/. - Add
ensure sals_kewlsignagetoserver.cfg. - Give yourself permission:
add_ace group.admin sals_kewlsignage.admin allow - Join and run
/sign demo. It teleports you in front of a field containing one of every sign type, each showing its plate number — the whole catalog in one look. - Run
/sign probe. Every type should say OK — the models ship instream/. MISSING means a model did not stream; DRAWN meansConfig.Models.UsePackis off and the type is on its fallback rig. - Before you go live, set
Config.Demo.Enabled = false.
The demo field ships on so that step 4 works on a fresh install, and the server console reminds you every boot until you turn it off. Demo signs are marked as config signs, so they cannot be deleted in-game — switching the setting off is how they go away.
Updating
Signs you place in-game are saved to data/signs.json, and
pictures added to the library to data/images/. Neither ships in
the download, so copying a new version over the old one leaves them alone. If
you prefer to delete the folder and drop the new one in, keep
data/ and put it back afterward. config.lua is
yours too; the CHANGELOG calls out any key that changed.
Coming from 1.5.0 or earlier, read Upgrading to 1.6.0 — one default changed in a way that can stop linked pictures working.
The fifteen sign types
Each has a plate number, which is what the demo field prints on it.
| Plate | type | What it is | Backend |
|---|---|---|---|
| SKS-101 | vms_trailer | Portable message board on a solar trailer | matrix |
| SKS-102 | arrow_trailer | Sequential arrow board | lamps |
| SKS-103 | dms_gantry | Full-span overhead gantry, with lane heads | matrix |
| SKS-104 | dms_cantilever | Half-span cantilever arm | matrix |
| SKS-105 | lcs_head | Single lane control signal | lamps |
| SKS-106 | radar_speed | Radar speed feedback sign | matrix |
| SKS-107 | vsl_roundel | Variable speed limit with zone beacons | matrix |
| SKS-201 | billboard | Digital billboard, full color | dui |
| SKS-202 | fuel_pylon | Fuel price pylon, per grade | matrix |
| SKS-203 | ticker | Facade ticker strip, scrolling | matrix |
| SKS-204 | aframe | Sidewalk A-frame | matrix |
| SKS-205 | menu_board | Drive-thru menu board, full color | dui |
| SKS-301 | parking_count | Parking space counter | matrix |
| SKS-302 | transit_panel | Transit arrivals panel | matrix |
| SKS-303 | marquee | Venue marquee with chasing bulbs | matrix |
How boards are drawn
Three backends, chosen per type:
matrix
Text as individual lamps on a 5×7 dot font, drawn on the plane of the board's face. Close up you see individual round lenses with dark gaps between them; further away, runs of lit lamps merge into solid strokes — which is what a real board looks like from a distance anyway, and costs a fraction of the polygons. Each lit lamp is drawn twice: a soft oversize halo in the lamp color under a whiter core, so neighboring lamps bleed into one glowing stroke the way LEDs do.
lamps
Fixed lamp patterns rather than text — the arrow boards and lane control signals, whose vocabulary is a set of shapes, not a set of words.
dui
An HTML page rendered off-screen and painted onto the board's face:
pictures, any typeface, a color per line, slide shows with fades. Used by
the billboard and the menu board out of the box, and available to any matrix
type through Config.Dui.Types. See
Full-color boards.
The hardware
Every type ships with a streamed model in stream/: a real
prop, lit by the game's sun and weather, casting and receiving shadows, with
collision. The screen is drawn onto a dark recess on the model, so the model
carries no text and no texture swap, and two boards on one model still say
two different things. Config.Models.Collision decides whether
players and cars hit the hardware — on by default, so a trailer blocks a
lane like a real one. If a model fails to stream, the type falls back to a
drawn rig from the same dimensions: unlit, no collision, but the board still
works. Config.Render.DrawRigs = false leaves such a sign screens
only.
Run /sign winding once, on your first install.
FiveM's DrawPoly draws one side of a triangle, and which side
depends on a vertex order a script cannot ask the renderer about. Stand in
front of the demo field and run /sign winding until the boards
look right, then paste the letter it prints into
Config.Render.Winding. The shipped default
'both' always works but costs twice as much — it is for
finding out, not for keeping.
The in-game editor
/signs, or bind a key in Config.Editor. Every
board on your server in a searchable list, with a live preview drawn using
the same font the game uses — and for a full-color board, the very page the
game paints onto it, so the preview is the board.
- Page-by-page message editing, with a per-line color, alignment and effect.
- One-click presets from
Config.Presets. - Place, move, duplicate and delete without touching a config file.
- Map pins for every sign, so you can see where they all are.
- Variables, schedule and mirroring, covered below.
- Watch board — a camera on the sign you are editing, with the panel still open and working beside it. The preview is a drawing of the message; this is the board itself, with the street behind it. In the tablet too.
- An arrow board or a lane signal has no text, so its preview is drawn as the hardware — the lamp grid running the real pattern.
With Config.Editor.FocusOnAimed, looking at a board and
pressing the bind opens the editor on that sign.
Permission is checked on the server before the UI is allowed to open, so leaving the command available to everyone is safe. A player with only the message permission sees the message editor; the group, schedule, mirror and Jobs allowed controls are admin-only.
The tablet
The editor is the control room. The tablet is what a road crew, a
traffic officer or a restaurant employee holds in their hand.
/signtablet, a key bind in Config.Tablet, or
using the signage_tablet item (registered as usable with
qb-core, qbx_core or es_extended when your inventory defines it) opens a
modern slate over the game, and the player holds a tablet prop while it is
open.
- Only the signs the player may run. An admin sees
everything; a job sees what
Config.Access.Jobsand each sign's own Jobs allowed list grant it — see Permissions below. The server checks the same thing on every save. - The same live preview the editor has.
- Message and presets as one-tap chips; pages with hold, effect and alignment. Pictures and per-line styles a slide carries are kept.
- A drive-thru board's item names and prices as plain fields.
- Power, brightness, color, the arrow pattern as tiles, lane signals as the heads themselves, and the values on a data board.
- Overrides, with Release.
- Quick actions from the list, multi-select with one action for all of them, one-tap alert modes, "show for 20 minutes" timed messages, a camera peek at any board, and an admin health screen with release-all.
Placing signs
Two ways, and they do not mix.
Config signs — the map's furniture
Signs in Config.Signs exist on every boot and cannot be
deleted in-game, so an admin cannot quietly remove the furniture and leave
the next restart to put it back. Their content can still be changed.
Config.Signs = {
{
id = 'vms.route68_nb',
type = 'vms_trailer',
coords = vector3(1234.5, -678.9, 60.1),
heading = 180.0, -- degrees the FACE points, 0 = north
group = 'highway',
pages = { { lines = { 'ROAD WORK', 'AHEAD' } } },
},
}
coords is the base of the sign — the ground,
not the screen. /sign here prints a ready-made block for
wherever you are standing.
Placed signs — the in-game tool
/sign place <type> [id] [group], or Place
new in the editor, gives you the real prop with a live face preview
to drive into position:
| Key | Does |
|---|---|
| W A S D | Move |
| Q / E | Up and down |
| Scroll | Turn |
| Arrow keys | Fine adjust |
| Space | Snap to 90° |
| R | Snap to the ground |
| Shift | Move faster |
| Enter | Place it |
| Backspace | Cancel |
Leave the id out and the sign is named after the street it lands on.
Placed signs are saved to data/signs.json and can be moved and
deleted freely.
Messages, pages and styles
A sign shows a list of pages, cycled with a hold time each. A line too long for the board scrolls rather than being cut.
-- the convenient forms, all equivalent to a page list
SetMessage('vms.route68_nb', 'ROAD WORK')
SetMessage('vms.route68_nb', { 'ROAD WORK', 'AHEAD' })
SetMessage('vms.route68_nb', {
{ lines = { 'ROAD WORK', 'AHEAD' }, hold = 2500 },
{ lines = { 'EXPECT', 'DELAYS' }, effect = 'flash' },
})
Every line can carry its own look through an optional styles
list beside lines. Lamp boards honor color, alignment and
flash; full-color boards add a typeface, a size, bold, italic, and the
effects pulse, glow, scroll and
rainbow.
{ lines = { 'HAPPY HOUR', '5 TO 7 PM' },
styles = { { color = '#FFD24A', size = 1.5, font = 'display', effect = 'glow' },
{ color = 'white', size = 0.9 } },
background = '#1A0B2E', transition = 'fade', hold = 5000 }
Config.Messages sets the ceilings — MaxLineLength
(32), MaxLines (4), MaxPages (8) — and the character
set the lamp font has glyphs for. They are applied on the server to
everything, including export callers, so no resource can push something that
renders as garbage.
Lines are stored as typed. The lamp boards apply their single case and charset when they draw, so a lamp board reads exactly as it always did, while a full-color board keeps its capitals and its accents. As of 1.6.0 the length cap counts characters rather than bytes, so accented text gets the same room as ASCII.
Overrides
An override jumps the queue without destroying what the board was showing, and releases itself:
Override('group:highway', { 'AMBER ALERT', 'BLU SEDAN' },
{ owner = 'my_dispatch', seconds = 300 })
When it expires — or when you release it — the board goes back to its own
pages on its own. That is how you do an emergency broadcast without every
resource having to remember and restore state. With an owner,
ClearOverride only releases overrides that owner placed, so two
resources overriding the same board cannot release each other's.
Full-color boards & pictures
The billboard and the menu board render an HTML page onto their face.
No model pack and no texture are involved, and every board has its own page,
so two billboards side by side show two different adverts.
Config.Dui.Types promotes any other message board — the marquee,
the A-frame, the ticker — to full color, keeping its hardware.
Drive-thru menus
A full-color page can carry a menu instead of lines,
and it is then laid out the way a menu is printed rather than as centered
text: a brand strip, sections set in columns under their own rules, calories
under each item name, prices in a column that lines up, and the nutrition
line along the bottom.
exports.sals_kewlsignage:SetMessage('menu.drivethru', { {
menu = {
theme = 'light', -- or 'dark' for a backlit panel
brand = "CLUCKIN' BELL", header = 'Order Here',
accent = '#C8102E', priceHeads = { 'Med', 'Lg' },
sections = {
{ header = 'Combo Meals', items = {
{ num = '1', name = 'Bell Box Meal', kcal = '1240 Cal',
price = '11.99', price2 = '13.49' },
} },
{ header = 'Sides & More', items = { --[[ ... ]] } },
},
footer = '2,000 calories a day is used for general nutrition advice',
},
} })
Every field is optional, and an item may also carry a desc and
an image — a library id or a link — drawn as a thumbnail against
the row. Sections are dealt into columns to suit the board's shape, and the
type is sized so the rows fill the panel without clipping a name.
A menu is content, not a texture. The same exports, overrides and schedules that change a message change the prices, so a script can run a daypart or a promotion. If the board cannot get a browser page it falls back to amber lamps, and the menu writes those lines itself.
Slides
A full-color board's pages are slides: picture, text or both, each with its own hold, background color, fill-or-fit, and a cut or a fade into the next. A picture-only slide is held eight seconds by default — it is an advert, not a message.
The picture library
Pictures live on the server in data/images/ (flat in
data/ if that folder is missing; the console says which), so
every player sees the same picture without anyone hosting it. Staff add them
from the editor: open a slide's Choose picture and paste one
with Ctrl+V — copied from
any app or web page, or a Win+Shift+S
screenshot.
There is no Browse button. The game's embedded browser cannot open a file picker, so a file input would sit there doing nothing. The clipboard works, and so does a link.
An uploaded picture is shrunk to the board it is going on before it leaves
the browser, so a screenshot of anything becomes a billboard-sized JPEG of a
couple of hundred kilobytes. A board refers to a library picture as
lib:<id>.
Linked pictures, and why they are off by default
A linked picture is not fetched by your server. It
becomes an <img src> inside the page painted onto the
board, on the machine of every player who can see it. So whoever can set a
board's picture can make everyone in view fetch a URL of their choosing —
an IP-logging endpoint handed to anyone with the message permission, and
nothing about the board looks wrong while it happens.
So Config.Images.AllowedHosts ships empty,
which refuses every link and allows only uploaded pictures. To allow links,
list the hosts:
AllowedHosts = { 'i.imgur.com', '.discordapp.net', 'media.tenor.com' },
A leading dot allows subdomains and only subdomains, so
.imgur.com covers i.imgur.com but not
eviimgur.com. '*' allows anything — only reasonable
on a server where everyone who can set a board's picture is someone you would
hand a shell to. The editor greys out the link box when no host is allowed,
rather than letting someone paste one and watch it be silently refused.
Settings
Config.Images sets the ceiling per picture
(MaxKilobytes, 600), the library size (MaxCount,
60), and whether the jobs in Config.Access.Jobs may upload
(AllowJobUploads, off — uploads are ACE by default, deleting
always is). Seed puts links in the library from the start, and
they go through the same allowlist.
Config.Dui.MaxSurfaces (default 6) is the pool of browser
pages shared by every full-color board in view, handed out nearest first. A
board without one renders as a matrix board until one frees up.
/sign perf reports what is in use.
Schedules
A sign can say different things at different times of day without anything driving it: a menu board that switches to breakfast at six, a marquee that runs the evening show after five, a highway board that reads differently overnight.
Config.Signs = {
{ id = 'menu.burgershot', type = 'menu_board',
coords = vector3(...), heading = 0.0,
pages = { { lines = { 'BURGER SHOT' } } }, -- outside every slot
schedule = {
{ from = '06:00', to = '11:00', label = 'Breakfast',
pages = { { lines = { 'BREAKFAST', 'UNTIL 11' } } } },
{ from = '22:00', to = '02:00', label = 'Late night',
pages = { { lines = { 'OPEN LATE' } } } },
} },
}
The first slot covering the current time wins, so the
list is a priority order — put the exception above the rule. When no slot
covers it, the board shows its own pages, which is what it did before it had
a schedule. A slot crossing midnight is written the obvious way and
understood: 22:00 to 02:00 is four hours, not
twenty.
An override always beats a schedule. An emergency broadcast is not something a menu board's breakfast slot should be able to interrupt.
A slot needs both times and something to show. A slot
with no pages is refused rather than accepted as a way to blank a board for
four hours — use SetPower for that. An existing
data/signs.json carrying one from an older version loses the
slot, never the sign.
Which clock
Config.Schedules.Clock:
'game'(default) — the in-game clock, which is what your players experience. A GTA day is 48 real minutes, so a scheduled board changes several times an hour of real time.'real'— the server's wall clock, for a server running synced or frozen time, or one that wants slots to line up with real events. Adayslist (1–7, Monday first) only means anything here; GTA's day of week is not something a server owner reasons about.
Slots are evaluated on the client, so a scheduled board changing costs no network traffic at all.
Settable at runtime with SetSchedule and
/sign schedule, and editable in the editor — where the slot that
is live right now is marked, because otherwise a scheduled board showing
something other than the message in the big box looks like a bug.
Template variables
A line may contain {name} placeholders, filled in from the
sign's own variables when it draws. The point is that a caller pushes the
message once and then only what changes:
SetMessage('group:highway', { 'DELAY {minutes} MIN', 'VIA {route}' })
SetVars('group:highway', { minutes = 12, route = 'ROUTE 68' })
-- later, every minute, without touching the message again:
SetVars('group:highway', { minutes = 9 })
SetVars merges, so setting one name does not wipe the others.
To remove one, use ClearVars(target, { 'route' }) — there is
deliberately no in-band "delete" value, because false is a
perfectly good thing for a variable to be (it renders as "no") and a
Lua table cannot carry a nil, so any sentinel would be ambiguous
with a real value. A name that has no value is
left on the board exactly as typed — a board reading
DELAY {minutes} MIN is a mistake you can see and fix, where
DELAY MIN is a mystery.
Substitution happens at draw time, so the stored message stays readable and the editor shows the template rather than one frozen instant of it.
Mirroring
Mirror(target, sourceId) makes a board show whatever another
board is showing — its pages, its override, its schedule, its type
behavior.
Mirror('dms.cantilever_nb', 'dms.gantry_nb')
Unmirror('dms.cantilever_nb')
Nothing is copied: the mirror reads through to its source every time it draws, so the two cannot drift and setting the source sets them both. A gantry's cantilever repeating the main board is the obvious use, and it is convincing.
Variables are still the mirroring board's own, so a row of near-identical signs can share one message and each fill in its own number.
Chains are followed eight deep; a cycle stops with the board showing nothing rather than with a client that has locked up. Deleting a source releases everything mirroring it back to its own pages.
Radar speed signs
The radar_speed type reads the approaching vehicle and
answers under a printed SPEED LIMIT plate. It only reads vehicles inside a
cone in front of it (Config.Radar.Range and
ConeDegrees), so a board does not read the traffic behind it,
and it flashes once the driver is FlashOver past the limit.
Config.Radar.Unit is 'mph' or 'kmh'
server-wide; a sign on a server that mixes them can carry its own:
/sign data radar.grove unit kmh
/sign data radar.grove limit 30
The plate above the screen is the reference the driver is being measured
against and is there the whole time. Config.Radar.Plate.Lines is
{ 'SPEED', 'LIMIT' } for the US plate; Canada would be
{ 'MAXIMUM' }.
Exports & events
Every export takes a target as its first argument and returns how many signs it changed, so a caller can tell the difference between "did it" and "that group is empty":
| Target | Means |
|---|---|
'vms.route68_nb' | one sign by id |
'group:highway' | every sign tagged into that group |
'all' | everything |
local sk = exports.sals_kewlsignage
-- content
sk:SetMessage(target, pages)
sk:Clear(target)
sk:Override(target, pages, { owner = 'my_resource', seconds = 300 })
sk:ClearOverride(target, owner)
-- lamps
sk:SetArrow(target, 'seq_right')
sk:SetLanes(target, { 'green', 'amberx', 'redx' })
-- numbers and switches
sk:SetData(target, { spaces = 41 }) -- parking counter
sk:SetData(target, { prices = { REG = '3.59' } }) -- fuel pylon
sk:SetData(target, { limit = 30 }) -- radar / variable limit
sk:SetPower(target, true)
sk:SetBrightness(target, 0.8)
sk:SetColor(target, 'amber')
-- variables, schedules, mirroring
sk:SetVars(target, { minutes = 12 })
sk:GetVars(id)
sk:ClearVars(target, names) -- names optional; omit for all
sk:SetSchedule(target, slots)
sk:GetSchedule(id)
sk:ClearSchedule(target)
sk:Mirror(target, sourceId)
sk:Unmirror(target)
-- placing and removing
sk:CreateSign({ id = ..., type = ..., coords = ..., heading = ..., group = ... })
sk:DeleteSign(id)
-- reading
sk:GetSign(id)
sk:ListSigns(target) -- or no argument for everything
sk:Types()
-- the picture library
sk:AddImage({ label = 'Sprunk', url = 'https://.../sprunk.png' })
sk:RemoveImage(id)
sk:ListImages()
-- moving a build between servers
sk:ExportSigns(includeStatic)
sk:ImportSigns(list, { replace = false, prefix = '', static = false })
Exports are not permission-checked. Server code is trusted by definition — if another resource can call this, it is already running on your server. Player-facing permission lives on the commands.
Events
For callers that would rather fire and forget. These are server-side events only and deliberately not net events: a client must never be able to reach them.
TriggerEvent('sals_kewlsignage:setMessage', 'group:highway', { 'FOG', 'SLOW DOWN' })
TriggerEvent('sals_kewlsignage:override', target, pages, opts)
TriggerEvent('sals_kewlsignage:clearOverride', target, owner)
TriggerEvent('sals_kewlsignage:setMode', 'group:highway', 'storm', { seconds = 1800 })
Alert modes, live variables, the radar hook
SetMode(target, name, { seconds }) puts every board in a
target into a named look from Config.Modes — amber alert,
storm, lockdown, all clear ship — as an override that beats a schedule and
releases itself; ClearMode puts them back. Live variables are
values every board can use in a {placeholder} with nothing
pushing them: {players}, {time},
{date}, {weather}, and
SetLiveVar('fuel_reg', '3.49') for the resource that knows the
number. A full-color slide can carry a video link that loops
behind the text, host-checked like a picture.
AddEventHandler('sals_kewlsignage:speeding', function(r)
-- r = { sign, plate, speed, limit, unit, over, driver, model, at }
end)
A radar sign that reads a driver Config.Radar.Report.Over
past the limit fires that once per driver per sign per cooldown. A client
only ever reports its own vehicle and the server reads the plate itself;
the speed is the trigger for a look, not a verdict.
And to listen — for a CAD bridge, a logger, or a board that mirrors another:
AddEventHandler('sals_kewlsignage:signChanged', function(id, record) end)
AddEventHandler('sals_kewlsignage:signRemoved', function(id) end)
Commands
A command that answers with a list — list,
types, the help — opens a panel rather than firing one
notification per row: it scrolls, it filters, you can copy out of it, and Esc
closes it. Short answers stay notifications.
/signs open the editor
/signtablet open the tablet
/sign mode <target> <mode | off> [secs] an alert mode from Config.Modes
/sign live [name] [value | -] live variables
/sign list what is placed, and its state
/sign types the catalog
/sign probe which models exist on this server
/sign perf what the boards are costing right now
/sign count what your client holds, and why a board
it holds is not being drawn
/sign winding cycle the one-sided draw setting, live
/sign demo teleport to the demo field
/sign place <type> [id] [group] drive it into position, ENTER to place
/sign move <id> pick an existing sign up and re-place it
/sign delete <id>
/sign here coords/heading block for Config.Signs
/sign face <type> nudge a type's screen onto its prop
/sign msg <target> ROAD WORK | AHEAD '|' splits lines
/sign clear <target>
/sign power <target> on|off
/sign arrow <target> <pattern>
/sign lanes <target> green,amberx,redx
/sign data <target> <key> <value>
/sign override <target> <seconds> <text>
/sign release <target>
/sign vars <target> what this board substitutes
/sign vars <target> <name> <value> set one ('-' drops it)
/sign schedule <target> the slots it runs
/sign schedule <target> add 06:00 11:00 BREAKFAST | UNTIL 11
/sign schedule <target> clear
/sign mirror <target> <source id>
/sign mirror <target> off
/sign image list the picture library
/sign image add <label> <url>
/sign image remove <id>
/sign export [all] write data/export.json
/sign import [replace] [prefix] read it back
/sign save flush placed signs to disk now
Permissions
Two tiers, because a roadworks crew should be able to set a board without being handed the keys to the map.
| Tier | Granted by | Can |
|---|---|---|
| Admin | the ACE permission sals_kewlsignage.admin | everything |
| Message | a job in Config.Access.Jobs, or a job named on a sign's Jobs allowed list | change what the signs it is granted say |
add_ace group.admin sals_kewlsignage.admin allow
Config.Access = {
AcePermission = 'sals_kewlsignage.admin',
Jobs = {
'dot', -- a plain name: every sign
police = { 'highway', 'traffic' }, -- only the signs in these groups
burgershot = { 'burgershot' }, -- its own boards and nothing else
},
RequireOnDuty = true,
}
The message tier is scoped per sign. A plain name grants every
sign; a name keyed to a list of group tags grants those groups; and an
admin can hand one sign to a job with Jobs allowed, next
to the group in the editor or on the tablet — which works with nothing in
config at all. The tablet lists only what the player may run, and the
server refuses a save, a release or a /sign subcommand that
reaches any other sign, including through all or a group
tag.
Admin only: place, move,
delete, face, save, demo
(it teleports you), mirror (it changes what two boards say),
export and import (they move the map). Regrouping a
sign and editing its schedule are admin-only in the editor for the same
reason, and so is the editor's Go to button — as of 1.6.1,
because it is the same free ride /sign demo is.
list, types and help need nothing.
Everything else needs the message permission.
Audit log
Who changed what, and when. With a job-level permission tier this is the difference between "somebody put something rude on the billboard" being a question and being an answer; and when a board is showing the wrong thing, the first useful fact is whether a person or a resource put it there.
Config.Audit has three sinks, independently switchable:
- The server console — off by default, because it is noisy on a busy server.
- A file,
data/audit.log. - A Discord webhook — the one that actually gets read.
Config.Audit = {
Enabled = true,
Console = false,
File = 'data/audit.log',
Webhook = 'https://discord.com/api/webhooks/...',
LogContent = true, LogPlacement = true, LogImages = true,
LogExports = false,
Ignore = { 'my_dispatch' },
}
Entries name the player with their license identifier, so the log is still
useful after they have left and their server id has been reused. Calls from
other resources are not logged by default: they are machine
traffic and can be constant. Ignore drops entries from named
override owners, for the dispatch resource that cycles a board every few
seconds.
The file is rewritten rather than appended — there is no append native —
so it is flushed on a timer, and when it passes
MaxFileKilobytes the contents move to
audit.log.1 and it starts again. That is one generation of
history; point the webhook at something that keeps more if you need it.
Rate limits
Everything a client can ask the server to do is capped per player in
Config.Limits. None of the limits are reachable by anyone
playing normally — they exist so one client in a loop cannot make the server
work on its behalf.
The two that matter are StateRequests, whose answer is the
whole sign registry plus the image library including thumbnails, and
Uploads, whose request carries a few hundred kilobytes.
Each setting is { how many, per how many seconds }. The first
time a player trips a limit the console says so once; further refusals are
counted rather than printed, so the log cannot itself be used to flood you.
/sign perf, run by an admin, lists who has been refused and how
often.
HTTP control
Optional and off by default, so a CAD, a Discord bot or a website can put a message on a board without anything running inside FiveM. It refuses to listen without a token — an open endpoint that rewrites every sign on the map is not a feature.
Routes, all POST with the token in the body:
/message, /release, /power,
/mode, /modes, /vars,
/live and /status. A Discord bot ships in
integrations/discord-bot/ with /sign message,
/sign alert, /sign status and friends driving
them, with role gating; its README has the bodies and the setup.
-- server.cfg
set sals_kewlsignage_http_token "a-long-random-string"
-- config.lua
Config.Http = { Enabled = true, TokenConvar = 'sals_kewlsignage_http_token' }
POST http://your-server:30120/sals_kewlsignage/message
{ "token": "...",
"target": "group:highway",
"pages": [ { "lines": ["AMBER ALERT", "BLU SEDAN"] } ],
"seconds": 300 }
With seconds it is an override that releases itself. Without,
it replaces what the board says.
Backup, export & import
Placed signs live in data/signs.json and pictures in
data/images/. Writes are batched a few seconds after the last
change, so a dispatch resource hammering a board does not hammer the disk;
/sign save flushes immediately, and so does stopping the
resource.
To move a build between servers:
/sign export -- writes data/export.json (placed signs)
/sign export all -- and the config signs too
/sign import -- skips ids that already exist
/sign import replace -- overwrites them
/sign import replace venue. -- and puts 'venue.' in front of every id
The export is in the same shape Config.Signs takes, so you
can paste it into a config file as easily as import it. A prefix lets a build
come in alongside one already using those ids, and a mirror inside an
imported build points at the imported copy of its source rather than at
whatever happens to hold that id already.
Validation is all or nothing: a list with one bad record imports nothing and tells you which ones failed, because half a map is worse than none of it.
Performance
/sign perf reports what the boards are actually costing:
boards drawing, triangles this frame, the peak, how many are at reduced
detail, and how many browser pages the full-color boards are using.
Four things keep it cheap, and they matter more than the drawing itself:
- Nothing allocates in the render loop.
- Lamp vertices are computed once into a flat buffer and replayed until something actually changes.
- Boards outside the camera frustum are rejected by one dot product.
- A hard triangle budget per frame, spent nearest board first — and spent on detail, not on existence. A board that does not fit at full detail draws at low detail rather than vanishing, because a board that disappears as you walk toward it is worse than any amount of simplification.
The dials
| Setting | Default | What it does |
|---|---|---|
TriangleBudget | 8000 | Triangles per frame the boards aim to stay under. A ceiling, not a cost. |
DrawDistance | 220 m | Past this a board's content is not drawn. The prop stays. |
DotDistance | 45 m | Inside this, individual lamps; outside, merged strokes. |
MaxDrawn | 16 | Never render more than this many boards' content at once. |
RoundLamps | true | Round lenses instead of squares. Four times the triangles per lit lamp. |
DrawUnlitLamps | true | The dark lens grid. The most expensive thing a small board can do. |
Winding | 'both' | Set this properly — see above. It halves the cost of everything. |
Config.Dui.MaxSurfaces | 6 | Browser pages shared by the full-color boards in view. |
Config.Dui.SurfaceSize | 1024 | Pixels per side of each surface. 2048 is four times the memory. |
The demo field, with fifteen rigs in one view, is the worst case you will
ever see. Run /sign perf there.
Models
Fifteen models ship in stream/, one per type, with the
.ytyp that declares them — about 600 KB in all. They are
generated from the catalog's own face and plate numbers, so the recess on
the model and the screen the renderer draws cannot drift apart. Each has two
LODs and box collision. stream/MODELS.md in the resource says
how the pack is built and how to replace any model with your own.
To swap a type onto a prop you already have, or a model of your own:
Config.Models = {
UsePack = true,
Collision = true, -- players and cars hit the hardware
Overrides = { billboard = 'prop_billboard_01' },
Faces = { billboard = { offset = vector3(0.0, 0.30, 8.40),
width = 14.60, height = 4.30 } },
Yaw = { billboard = 180.0 }, -- for a prop modeled facing its own -Y
Scale = { radar_speed = 1.5 }, -- fallback rigs only
}
Do not guess the face. Stand in front of a board, run
/sign face <type>, nudge the screen onto the prop with the
arrow keys and PgUp/PgDn,
then press Enter and paste the line it prints.
Troubleshooting
The screens are invisible, or the rigs look inside out
The winding setting is wrong. Run /sign winding until they
look right and paste the letter into Config.Render.Winding. On
'both' screens show but boxes look hollow — that is for finding
out, not for keeping.
A board is floating, or sunk into the ground
coords is the base of the sign, not the screen. Use
/sign move <id> and R to snap it
to the ground, or nudge the z.
/sign probe says MISSING
A shipped model did not stream. Check stream/ is intact —
fifteen .ydr files and salskewl_signage.ytyp — and
that the data_file line is still in fxmanifest.lua.
For an override, the prop named in Config.Models.Overrides is
not on this server. DRAWN means Config.Models.UsePack is off.
A full-color board is showing amber lamps
It has not been given a browser page yet — the pool goes to the nearest
boards first. If it never gets one, raise
Config.Dui.MaxSurfaces. If every full-color board
shows lamps, the console will say this client build has no
DrawTexturedPoly native.
Placed signs vanish on restart
The console will be warning that it cannot write
data/signs.json. A script cannot create a folder, so
data/ has to exist — it ships in the download, so this usually
means it was removed while copying.
A pasted picture is refused
Check the size against Config.Images.MaxKilobytes and the
count against MaxCount. If it was a link, see
linked pictures — the host allowlist ships empty.
A scheduled board is not showing what I typed in the message box
A schedule slot has taken over. The editor marks the live slot. Remember that on the in-game clock a GTA day is 48 real minutes, so slots come round far faster than you might expect.
A board reads DELAY {minutes} MIN
That variable has no value set. This is deliberate — a placeholder left
visible is a mistake you can find, where a silent gap is not. Set it with
/sign vars or in the editor.
Upgrading to 1.6.0
One default changed in a way that can stop something working.
Linked pictures are now refused unless their host is allowed.
Config.Images.AllowedHosts ships empty, which means uploads
only. If you use linked pictures, run /sign image list to see
what you are using and add those hosts, or set
AllowedHosts = { '*' } to keep the old behavior. Uploaded
pictures are unaffected. Deleting the key entirely also keeps the old
behavior, for a server that upgrades without reading this.
Everything else is additive or a fix. Worth knowing:
Config.Demo.Enablednow ships true, so a fresh install can run/sign demo. Your existingconfig.luais untouched./sign demonow needs the admin permission — it teleports you, and it used to be reachable with the message permission.- Full-color surfaces are now square and interchangeable
(
Config.Dui.SurfaceSize). Previously a pool that filled with billboard-shaped pages had nothing to give a menu board, which then rendered as an amber matrix board for the rest of the session. No config change needed. MaxLineLengthnow counts characters rather than bytes, so accented text gets its full line. Lines on non-English servers will get longer, not shorter.
Support
Questions, bug reports and feature requests:
the
Discord. Include your server's framework, the resource version from
fxmanifest.lua, and the output of /sign probe and
/sign perf — between them they answer most of what anyone would
ask first.
The resource's own README.md ships in the download and is the
source of truth; this manual is the same content organized for reading in a
browser.