Skip to content

Images, text & particles ​

clientside

Script 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 findimg again returns the same image, so it's normal to call findimg(id) every frame in onUpdate and 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, text or polygon, 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.
  • findimg has to know which script is calling, and throws findimg/hideimg need a script context when called outside one. Calling it from handlers and onCreated is 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
UnitsWorld tiles (tile t sits at pixel t·16), same as player.xScreen pixels from the window's top-left
CameraScrolls with the worldFixed on screen
DrawsInside the world pass, by layer bandAbove the whole world and chat bubbles, below GUI
LightingDarkened by ambient lightNever 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–3 draw under NPCs and players (ground decals, shadows, target circles), each layer over the previous. Layers 4+ draw above players and projectiles.
  • Screen images: plain z-order among screen images, 0 lowest.

Negative values clamp to 0 and fractions truncate. Images on the same layer draw in (script, id) order.

Appearance ​

PropertyEffect
imageAsset 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 / heightExplicit size in pixels, stretching the source; -1 = natural size.
zoomScale factor.
alpha0 (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.
visibleshow() / 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 ground

Text ​

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.

PropertyEffect
styleAny of 'b' (bold), 'i' (italic), 'c' (centered on x), e.g. 'bc'.
fontsizeText size (default 16, clamped 1-256). Composes with zoom: fontsize 32 ≡ zoom 2.
fontFont family; '' is the built-in font. Unknown names fall back to it.
textshadowDraws 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:

FieldMeaning
widthWidth of the widest line, px.
heightTotal height (lines.length × lineheight).
lineheightHeight of one line for that font/size/style.
linesThe 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 ​

PropertyDefaultMeaning
delaymin / delaymax0.5Random seconds between automatic bursts (min 0.05).
nrofparticles1Particles per burst (max 100).
maxparticles100Live particle cap for this emitter (max 1000).
emitautomaticallytruefalse stops automatic bursts; emit() still fires one manually.
isfrozenfalsePauses the emitter and all its particles (they're still drawn).
attachpositionfalseKeeps live particles relative to the emitter as it moves.
firstinfronttrueFirst-emitted particle draws in front.
autorotationfalsePoints 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:

FunctionClockModifies
addlocalmodifierEach particle's ageThat particle
addglobalmodifierThe emitter's ageAll live particles at once
addemitmodifierThe emitter's ageThe particle template (future particles)

And the schedule type:

  • 'once' — fires when the clock passes rangemin, applying a random value in [valuemin, valuemax].
  • 'impulse' — refires at random intervals of rangemin..rangemax seconds.
  • 'range' — active over the clock window [rangemin, rangemax]. With 'replace' it sets the value interpolated from valuemin to valuemax; 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 speed

removemodifiers() 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 hideimg to 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 naturally

The particle effects tutorial builds several complete effects.