Your First Feature
We're going to build a targeting visualiser — a feature that draws a circle on screen and highlights the nearest target inside it. It's a real, useful feature and it touches every major concept in Pulse.
By the end you'll understand exactly what Pulse does for you, what you still write yourself, and why things are structured the way they are.
What we're building
- A circle drawn at the centre of the screen showing the targeting area
- A button to lock onto the nearest target inside that circle
- A UI toggle to enable/disable it
- A slider to control the circle size
Step 1 — Create the file
Create src/combat/Targeter.ts.
The filename becomes the component name. At runtime, this component is accessible globally as Targeter.
Step 2 — Declare state with signals
defineComponent('Targeter', () => {
const enabled = signal(false)
const radius = signal(300)
const showCircle = signal(true)
Three pieces of reactive state. Right now they have no effect — they're just values. But because they're signals, anything connected to them will react when they change.
Why signals and not regular let variables?
Regular let variables can't drive UI widgets and can't be observed from outside the component. Signals can. If a value needs to appear in the UI or be read by another component, it must be a signal.
Step 3 — Internal state and helpers
let circle: PulseDrawObject | null = null
function getTargets(): Model[] {
const folder = workspace.FindFirstChild('Enemies') as Folder | undefined
return folder ? (folder.GetChildren() as Model[]) : []
}
function getAimRoot(entity: Model): BasePart | undefined {
return (entity.FindFirstChild('Head')
?? entity.FindFirstChild('HumanoidRootPart')) as BasePart | undefined
}
circle is a regular let variable — it's internal bookkeeping, not reactive. getTargets is something you write: Pulse provides Pulse.Aim.findNearest to find the closest target, but it doesn't know what folder your game stores enemies in.
Step 4 — The render loop
on.renderStepped({ when: enabled }, () => {
if (!circle) return
const cam = workspace.CurrentCamera
if (!cam) return
const sz = cam.ViewportSize
circle.Position = new Vector2(sz.X * 0.5, sz.Y * 0.5)
circle.Radius = radius()
circle.Visible = showCircle()
})
on.renderStepped binds to RunService.RenderStepped. The { when: enabled } guard means the handler is skipped entirely when enabled() is false — no branching inside, no cleanup needed.
The connection is tracked by the framework and disconnected automatically when the script is re-run or destroyed.
Step 5 — React to enable/disable
on.signal(enabled, (v) => {
if (v) {
if (!circle) {
circle = Pulse.Draw.circle({
color: Color3.fromRGB(255, 255, 255),
thickness: 2,
alpha: 0.8,
filled: false,
visible: false,
})
}
Pulse.Log.info('Targeter', 'enabled')
} else {
Pulse.Draw.remove(circle)
circle = null
Pulse.Log.info('Targeter', 'disabled')
}
})
on.signal fires synchronously whenever enabled changes. circle is captured from the outer closure — the handler always sees the current reference.
Step 6 — Lock onto a target
function lockNearest() {
const targets = getTargets()
const nearest = Pulse.Aim.findNearest(targets, {
fovRadius: radius(),
getRoot: getAimRoot,
})
if (nearest) {
Pulse.Log.info('Targeter', 'locked', { name: nearest.Name })
} else {
Pulse.Log.debug('Targeter', 'no target in radius')
}
}
Pulse.Aim.findNearest checks each target's screen position against the FOV circle and returns the closest one. You hand it the list and the options.
Step 7 — Return the UI widgets
return [
toggle('Enable Targeter', { default: true }).bind(enabled),
slider('Radius', { min: 50, max: 800 }).bind(radius),
toggle('Show Circle', { default: true }).bind(showCircle),
button('Lock Nearest', lockNearest),
]
})
The returned array is the widget declaration. Each widget is bidirectionally bound to its signal — the UI updates when the signal changes, and the signal updates when the user interacts.
{ default: true } sets the initial signal value to true one second after load, which triggers the on.signal(enabled, ...) handler and auto-starts the feature.
Step 8 — Add it to a page
Open (or create) src/pages/1_Combat.ts:
definePage('Combat', { icon: 'swords' }, () => [
groupbox('left', 'Targeting', { icon: 'crosshair', mount: 'Targeter' }),
])
mount: 'Targeter' renders the widgets returned by the Targeter component into this groupbox.
The complete file
// src/combat/Targeter.ts
defineComponent('Targeter', () => {
const enabled = signal(false)
const radius = signal(300)
const showCircle = signal(true)
let circle: PulseDrawObject | null = null
function getTargets(): Model[] {
const folder = workspace.FindFirstChild('Enemies') as Folder | undefined
return folder ? (folder.GetChildren() as Model[]) : []
}
function getAimRoot(entity: Model): BasePart | undefined {
return (entity.FindFirstChild('Head')
?? entity.FindFirstChild('HumanoidRootPart')) as BasePart | undefined
}
on.renderStepped({ when: enabled }, () => {
if (!circle) return
const cam = workspace.CurrentCamera
if (!cam) return
const sz = cam.ViewportSize
circle.Position = new Vector2(sz.X * 0.5, sz.Y * 0.5)
circle.Radius = radius()
circle.Visible = showCircle()
})
on.signal(enabled, (v) => {
if (v) {
if (!circle) {
circle = Pulse.Draw.circle({
color: Color3.fromRGB(255, 255, 255),
thickness: 2,
alpha: 0.8,
filled: false,
visible: false,
})
}
Pulse.Log.info('Targeter', 'enabled')
} else {
Pulse.Draw.remove(circle)
circle = null
Pulse.Log.info('Targeter', 'disabled')
}
})
function lockNearest() {
const targets = getTargets()
const nearest = Pulse.Aim.findNearest(targets, {
fovRadius: radius(),
getRoot: getAimRoot,
})
if (nearest) {
Pulse.Log.info('Targeter', 'locked', { name: nearest.Name })
} else {
Pulse.Log.debug('Targeter', 'no target in radius')
}
}
return [
toggle('Enable Targeter', { default: true }).bind(enabled),
slider('Radius', { min: 50, max: 800 }).bind(radius),
toggle('Show Circle', { default: true }).bind(showCircle),
button('Lock Nearest', lockNearest),
]
})
What you wrote vs what Pulse handled
| You wrote | Pulse handled |
|---|---|
getTargets() — your game's folder path | RenderStepped connection and cleanup |
getAimRoot() — which part to target | UI ↔ signal bidirectional binding |
| The lock logic | when guard early-return compilation |
| Enable/disable state management | Connection disconnect on re-run/destroy |
| — | Log buffering and dev overlay display |
Common issues at this stage
"The circle doesn't appear"
Check that enabled is true and showCircle is true. The on.signal(enabled, ...) handler creates the circle on first enable — if it never ran, the circle was never created. Add Pulse.Log.info('Targeter', 'enabled') and check the dev overlay log.
"_PulseGetHumanoid() returns undefined"
The player is dead, in a vehicle, or hasn't spawned yet. This is correct behaviour — always guard before accessing character properties.
"lockNearest always says no target"
Your getTargets() is returning an empty array. Check workspace.FindFirstChild('Enemies') — the folder name is game-specific. Log the count: Pulse.Log.debug('Targeter', 'count', { n: targets.size() }).
Next steps
- Signals — understand reactive state in depth
- Components — full component syntax reference
- Pulse.Aim — all targeting helpers
- Common Mistakes — read this before your second feature