Skip to main content

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 wrotePulse handled
getTargets() — your game's folder pathRenderStepped connection and cleanup
getAimRoot() — which part to targetUI ↔ signal bidirectional binding
The lock logicwhen guard early-return compilation
Enable/disable state managementConnection 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