Appearance
Images, text & particles
clientsideScript images are lightweight, client-only drawables: sprites, colored rectangles, polygons and text, placed either in the world (scrolling with the camera) or on the screen. Each image can also carry a particle emitter — a clone of Graal's particle engine — for smoke, sparks, rain and similar effects.
Images are the right tool for anything visual that isn't interactive: floating damage numbers, toast notifications, ground markers, selection outlines, HUD bars, weather. For clickable interface, use GUI controls, which always draw above images.
Everything here is clientside and never seen by the server or other players. If other players should see an effect, trigger it on each client (for example from a clientside NPC script, or by sending a trigger to every client).
findimg and hideimg
findimg(id) returns this script's ScriptImage with the given numeric id, creating it on first use. hideimg(id) destroys it (the Graal convention; findimg(id).destroy() does the same).
ts
// weapons/marker.client.ts
export function onCreated() {
const img = findimg(1)
img.image = 'images/arrow.png'
img.x = player.x // world tiles, like player.x
img.y = player.y - 2
img.layer = 4 // above players (the default)
}- Ids are per script. Another weapon's
findimg(1)is a different image, so pick ids freely inside one script (many scripts reserve ranges, e.g. 200+ for toasts). - Calling
findimgagain returns the same image, so it's normal to callfindimg(id)every frame inonUpdateand just assign the properties that changed. - Writes are batched: they're validated at the assignment site (bad values throw immediately), readable back at once, and coalesced into one update per image per flush.
- With no
image,textorpolygon, an image draws a solid rectangle in its tint color —width×height, or 16×16 pixels while those are unset. Configure a new image in the same handler that creates it; the batched writes land together, so it never flashes a white square. findimghas to know which script is calling, and throwsfindimg/hideimg need a script contextwhen called outside one. Calling it from handlers andonCreatedis always safe.
Images work in weapons, classes and clientside NPC scripts. They're swept automatically when the owning script unloads — a weapon being removed or reloaded, or a level NPC's script when you leave its level.
World vs screen space
The screen flag picks the coordinate space for x/y:
screen = false (default) | screen = true | |
|---|---|---|
| Units | World tiles (tile t sits at pixel t·16), same as player.x | Screen pixels from the window's top-left |
| Camera | Scrolls with the world | Fixed on screen |
| Draws | Inside the world pass, by layer band | Above the whole world and chat bubbles, below GUI |
| Lighting | Darkened by ambient light | Never lit |
Screen images are what you use for HUD elements; center them with ScreenWidth / ScreenHeight, which track window resizes. See Camera, input & movement for converting between the two spaces.
Layers
layer (default 4) controls draw order:
- World images: layers
0–3draw under NPCs and players (ground decals, shadows, target circles), each layer over the previous. Layers4+ draw above players and projectiles. - Screen images: plain z-order among screen images,
0lowest.
Negative values clamp to 0 and fractions truncate. Images on the same layer draw in (script, id) order.
Appearance
| Property | Effect |
|---|---|
| image | Asset name, e.g. 'images/pics1.png'. '' draws a solid width×height rectangle of the tint color. |
| imagerect | "left,top,width,height" source region (sprite sheets). |
| width / height | Explicit size in pixels, stretching the source; -1 = natural size. |
| zoom | Scale factor. |
| alpha | 0 (invisible) to 1 (opaque). |
| tint | "r,g,b" 0-255 multiplier. Same color as red/green/blue (0-1 each) — writing one updates the other. |
| visible | show() / hide() toggle it without destroying the image. |
A health bar is just two rectangles:
ts
function drawHealthBar(hp: number, max: number) {
const back = findimg(10)
back.screen = true
back.x = 20; back.y = 20
back.width = 104; back.height = 12
back.tint = '0,0,0'
back.layer = 0 // screen layers are pure z-order
const fill = findimg(11)
fill.screen = true
fill.layer = 1 // over the background
fill.x = 22; fill.y = 22
fill.width = Math.round(100 * hp / max); fill.height = 8
fill.tint = '200,40,40'
}Polygons
polygon draws a solid filled shape instead of the image: a flat list of x,y pairs (at least three). Vertex units follow screen (tiles or pixels), x/y offset every vertex and zoom scales them about that anchor — so with the defaults the coordinates are absolute. Fill is even-odd, so concave and self-intersecting shapes work. Assign [] to clear it.
ts
const zone = findimg(5)
zone.polygon = [10,10, 14,10, 14,13, 10,13] // a 4x3-tile area
zone.tint = '80,160,255'
zone.alpha = 0.35
zone.layer = 0 // on the groundText
Setting text to a non-empty string renders text instead of the image or rectangle. Text respects x/y, layer, screen, zoom, color and alpha; width, height and imagerect are ignored.
| Property | Effect |
|---|---|
| style | Any of 'b' (bold), 'i' (italic), 'c' (centered on x), e.g. 'bc'. |
| fontsize | Text size (default 16, clamped 1-256). Composes with zoom: fontsize 32 ≡ zoom 2. |
| font | Font family; '' is the built-in font. Unknown names fall back to it. |
| textshadow | Draws a drop shadow behind the text. |
| shadowoffset | "x,y" pixels, default "1,1"; scales with the text size. |
| shadowcolor | "r,g,b", default black. The tint never applies to the shadow. |
Text is rasterized at the exact size it's drawn at (fontsize × zoom), so it stays crisp at any size. A centered caption on screen:
ts
const t = findimg(100)
t.screen = true
t.text = 'Level up!'
t.style = 'bc'
t.x = ScreenWidth / 2 // 'c' centers on x
t.y = 80
t.fontsize = 24
t.textshadow = true
t.tint = '255,220,0'Fonts
The client currently ships a single text-image font family (Open Sans, in regular, bold, italic and bold-italic), so font names other than '' render in that same face.
Measuring text
measuretext(text, opts?) measures text the way a GuiTextCtrl label renders it and returns a TextMeasurement:
| Field | Meaning |
|---|---|
width | Width of the widest line, px. |
height | Total height (lines.length × lineheight). |
lineheight | Height of one line for that font/size/style. |
lines | The rendered lines — explicit newlines plus word-wrapping when opts.width is given. |
opts takes the label props font, fontsize and style (omitted = label defaults) and a width: when it's greater than 0 the text is word-wrapped exactly like a label of that width, so lines is what the label would draw. This makes pagination straightforward — a message box can wrap a long message and cuts it into pages that fit its window:
ts
const m = measuretext(message, { width: WIDTH - PADX * 2 })
const perPage = Math.max(1, Math.floor((HEIGHT - PADY * 2) / m.lineheight))
const pages: string[] = []
for (let i = 0; i < m.lines.length; i += perPage)
pages.push(m.lines.slice(i, i + perPage).join('\n'))measuretext is a direct call that returns a value immediately. Its metrics describe GUI label rendering; use it for labels, and treat it as an approximation for text images.
Particle emitters
Every image has a lazily-created emitter (ParticleEmitter). It does nothing until configured, sits at the image's position (plus emissionoffset), and spawns particles that then fly on their own. Particle distances are in tiles and speeds in tiles per second.
ts
// A chimney: grey puffs drifting up, growing and fading out.
export function onCreated() {
const img = findimg(1)
img.x = 30; img.y = 20 // the emitter's world position
const e = img.emitter
e.delaymin = 0.2; e.delaymax = 0.4 // seconds between bursts
e.nrofparticles = 1
e.particle.image = 'images/smoke.png'
e.particle.lifetime = 3
e.particle.angle = Math.PI / 2 // pi/2 = up the screen
e.particle.speed = 0.8
e.particle.alpha = 0.7
e.addlocalmodifier('range', 0, 3, 'zoom', 'add', 0.5, 0.5) // +0.5 zoom per second
e.addlocalmodifier('range', 1, 3, 'alpha', 'replace', 0.7, 0) // fade over its last 2s
}Emitters are world-anchored
The emitter origin always reads the image's x/y as a world position, even on a screen = true image. For effects on the HUD, position the image in world tiles instead.
Emission
| Property | Default | Meaning |
|---|---|---|
delaymin / delaymax | 0.5 | Random seconds between automatic bursts (min 0.05). |
nrofparticles | 1 | Particles per burst (max 100). |
maxparticles | 100 | Live particle cap for this emitter (max 1000). |
emitautomatically | true | false stops automatic bursts; emit() still fires one manually. |
isfrozen | false | Pauses the emitter and all its particles (they're still drawn). |
attachposition | false | Keeps live particles relative to the emitter as it moves. |
firstinfront | true | First-emitted particle draws in front. |
autorotation | false | Points each particle's rotation along its movement. |
For one-shot effects (an explosion, a hit spark) set emitautomatically = false and call emit() whenever you need a burst. removeparticles() clears every live particle. currentparticlecount and emittedparticles are live reads, handy for debugging.
The particle template
particle is a ParticleTemplate: the attributes the next emitted particles start with — spawn offset x/y, angle + speed plus extra movex/movey, spin, rotation, zoom, stretchx/stretchy, red/green/blue, alpha, lifetime (seconds, max 60) and image ('' = a solid 16×16 square). mode selects blending: 0 additive (glows, fire), 1 normal (default), 2 subtractive. zangle is accepted for Graal compatibility and ignored.
Modifiers
Modifiers change particle variables over time. All three add…modifier functions take (type, rangemin, rangemax, variable, modtype, valuemin, valuemax); they differ in whose clock they use and what they modify:
| Function | Clock | Modifies |
|---|---|---|
| addlocalmodifier | Each particle's age | That particle |
| addglobalmodifier | The emitter's age | All live particles at once |
| addemitmodifier | The emitter's age | The particle template (future particles) |
And the schedule type:
'once'— fires when the clock passesrangemin, applying a random value in[valuemin, valuemax].'impulse'— refires at random intervals ofrangemin..rangemaxseconds.'range'— active over the clock window[rangemin, rangemax]. With'replace'it sets the value interpolated fromvaluemintovaluemax; with'add'the interpolated value is a rate per second.'multiply'is not allowed for ranges (it throws).
Modifiable variables: x, y, movex, movey, angle, speed, rotation, spin, stretchx, stretchy, red, green, blue, alpha, zoom. Each function returns a ParticleModifierHandle whose addmod chains extra (variable, modtype, valuemin, valuemax) changes onto the same schedule:
ts
e.addlocalmodifier('once', 0, 0, 'angle', 'replace', 0, Math.PI * 2) // random direction
.addmod('speed', 'replace', 1, 3) // random speedremovemodifiers() drops them all.
Clipping, drop emitters and cleanup
- clippingbox destroys particles that leave an emitter-relative box (tiles); cliptoscreen uses the visible screen instead, and wraptoclippingbox wraps them to the other side — the classic setup for screen-wide rain or snow.
- dropemitter is a sub-emitter that bursts where this emitter's particles expire (lifetime end only; clipped particles don't trigger it) — raindrops that splash, fireworks that burst. It's one level deep.
- An emitter is destroyed with its image. Set continueafterdestroy before
hideimgto let live particles finish their lifetime instead of vanishing:
ts
const e = findimg(7).emitter
e.continueafterdestroy = true
hideimg(7) // particles already in flight fade out naturallyThe particle effects tutorial builds several complete effects.