Assets

Dedicated server builds can omit assets used only by views and other client code. Assets reached by simulation helpers, defaults and dynamic match asset references remain available. Server and client retain the same match identity even when their packs differ. Projects with custom C retain assets conservatively unless explicitly opting into pruning.

An asset is a file a game is built with: an image, a model, a text, some bytes. The language names it, the compiler checks it, and the build packs what's named and nothing else. The second half of this page covers what a machine loads while the game runs instead: a file of the player's, or a URL, which is local to that machine until the match is told about it.

Supported formats

Asset Build-time source formats What reaches the game
Texture, Grid2<Color>, cubemap faces PNG, PSD, JPEG (.jpg, .jpeg), TGA, BMP Decoded pixels; textures and cube faces use RGBA8
Mesh<V> glTF 2.0 (.gltf, .glb), binary or ASCII FBX, OBJ Static vertices and triangles
SkinnedMesh<V> glTF 2.0, binary or ASCII FBX Geometry, skeleton and animation; see Animation
AnimationCatalog glTF 2.0 (.gltf, .glb), binary or ASCII FBX Skeleton and every animation clip; see Animation files
Animation glTF 2.0 (.gltf, .glb), binary or ASCII FBX The only clip, or an exact [Name("source name")] selection
Clip WAV, Ogg Vorbis, MP3, FLAC WAV/Vorbis bytes, or float PCM WAV converted from MP3/FLAC; see Audio
string Any UTF-8 text file Text
List<byte> Any file Its original bytes

These are source imports. Runtime grid.Decode reads PNG only, and runtime SkinnedMesh.Decode reads self-contained GLB only. AnimationCatalog has no runtime Decode operation. A supported extension doesn't imply every feature of that format: the image and model limits below say what is read.

WebP, TIFF, animated images, SVG, HDR/EXR and GPU texture containers such as DDS/KTX2 aren't image imports yet. USD, Collada (.dae), Alembic, STL, PLY and Blender project files aren't model imports; export a model as glTF 2.0 or FBX, or OBJ for static geometry. Model materials and their textures still need separate declarations and draws.

Custom font assets (TTF/OTF) and video playback aren't built. Draw.Text uses Tide's bundled font. Any file can still be packed as List<byte>, which preserves its bytes without interpreting them.

Declaring an asset

asset declares a file's contents under a name, at the top of a file like a constant. The type is what the file is read as:

tide
asset Grid2<Color> hero = "art/hero.png";   // An image, decoded as the game is built: cells code reads
asset Texture tiles = "art/tiles.psd";      // An image that's only drawn
asset string rules = "rules.txt";           // UTF-8 text
asset List<byte> table = "data/table.bin";  // Any file, as its bytes

Code reads hero wherever it reads a Grid2<Color>: a system indexes its cells, a view draws it as a texture with Draw.Mesh, and a frag samples it by name, hero.Sample(uv), as it samples a singleton's grid. Those four are the plain ones: string, List<byte>, Grid2<Color> and Texture. A sky is a Cubemap, a static model a Mesh, a model with bones a SkinnedMesh, animation clips an AnimationCatalog asset, and a sound a Clip.

A sprite from a fileLive Tide example, tap it to play
Open separately
tide
settings { title = "A sprite from a file"; maxPlayers = 1; }

// The file is read as the game is built and packed with it: every machine has
// the same pixels, so match code can read them too.
asset Grid2<Color> sprite = "art/sprite.png";

component Bouncer
{
    float2 position;
    float2 velocity;
}
singleton Arena { float2 half = float2(5, 4); }
scene Main { }

event(Spawned) Setup(with Main)
{
    Spawn(Bouncer { position = float2(-2, 1), velocity = float2(3, 2) });
    Spawn(Bouncer { position = float2(2, -1), velocity = float2(-2, 3) });
}

// The match reads the sprite like any grid: here its size, to bounce by its edges.
system Move(mut Bouncer bouncer, Arena arena, Time time)
{
    var extent = float2(sprite.size.x, sprite.size.y) / 24 * 0.5;
    bouncer.position += bouncer.velocity * time.dt;
    if (bouncer.position.x < -arena.half.x + extent.x || bouncer.position.x > arena.half.x - extent.x) bouncer.velocity.x = -bouncer.velocity.x;
    if (bouncer.position.y < -arena.half.y + extent.y || bouncer.position.y > arena.half.y - extent.y) bouncer.velocity.y = -bouncer.velocity.y;
}

view Background(Arena arena)
{
    Draw.Clear(Color(0.086, 0.086, 0.094));
    Draw.SetCamera(float2(0), arena.half.y * 2);
    Draw.WireRect(float2(0), arena.half * 2, Color(0.24, 0.25, 0.27));
}

// A view draws it as a texture, point-sampled so its pixels stay crisp.
view Sprites(Bouncer bouncer)
{
    var p = bouncer.position;
    var h = 0.5;
    Draw.Mesh([Vertex { position = p + float2(-h, h), uv = float2(0, 0), color = Color.white },
                 Vertex { position = p + float2(h, h), uv = float2(1, 0), color = Color.white },
                 Vertex { position = p + float2(h, -h), uv = float2(1, 1), color = Color.white },
                 Vertex { position = p + float2(-h, -h), uv = float2(0, 1), color = Color.white }],
                [0, 1, 2, 0, 2, 3], sprite, Filter.Point);
}

An asset is read-only. Nothing assigns it, takes it mut, or clears it: it's the file as the game was built, the same on every machine. To change pixels, keep a Grid2<Color> field of your own and copy cells in.

Match code reads assets like any value, since every machine has the same pack; a Texture is the one that's local code's alone (below). What a machine loads as the game runs is another matter, further down.

Release builds

A standalone tide build --release omits asset declarations that no checked Tide code, default or shader references. An asset named in a helper or a type's default stays, even if that helper or type is unused. A referenced list or map keeps every file its path matches. Every declaration is still checked, so an unused asset with a missing or invalid file remains a build error.

Debug and hot-reload builds keep every declared asset. So do games or packages with C/C++ sources or prebuilt libraries, and games with a custom C host: their C code may read assets through the generated API or by name in the pack.

A web release also compresses the retained pack inside its self-contained page when that saves space. The page decodes the same packed bytes before the game starts; the asset values and match results stay the same.

A field's default

A field's default can name an asset, as it names a constant:

tide
asset Texture logo = "art/logo.png";
asset string motto = "motto.txt";
asset List<byte> table = "data/table.bin";

struct Sprite
{
    Texture image = logo;
    Color tint = Color.white;
}

component Sign
{
    string text = motto;         // The match's own copy of the text
    int size = table.count;      // An expression can read one
    local Texture face = logo;   // This machine's, as every Texture is
    local Sprite art;            // Its image starts as the logo too
}
  • The field gets the asset's value where the default is given: as an entity is spawned, as a match or this machine's local state starts, and for an entity's local fields nothing wrote yet.
  • Text, bytes and a glob's list or map become independent field values: changing one changes neither the asset nor another field. Asset-backed lists can share immutable bytes until the first write, when that value detaches into owned storage. A field keeps the value it was assigned across asset reloads.
  • A Texture, a Clip and a Cubemap name their asset, so they cost nothing to give, and under tide run a saved file shows through them.
  • A Grid2<Color> field can't start as an asset's grid, since a grid is never copied: read the asset's by its name, or give it to a Texture field, Texture face = hero;.
  • One of a glob's, like run[0] or icons["art/icons/star.png"], isn't a default yet: a view assigns it.
  • Only a field's default takes one. A const, a setting, an attribute's bound, an asset's path and a parameter's default are constants alone, as the compiler works those out while it builds.

Images to draw with

Most of a game's images are only ever drawn: sprites, tile sheets, backgrounds, icons. Those are Textures:

tide
asset Texture hero = "art/hero.png";
asset List<Texture> run = "art/run/*.png";
asset Map<string, Texture> icons = "art/icons/*.png";

local singleton Player
{
    int frame;
    Texture badge;   // Whichever image it's given
}

view Show(mut Player player)
{
    Draw.Mesh(corners, faces, hero);
    Draw.Mesh(corners, faces, run[player.frame % run.count], Filter.Point);
    player.badge = icons["art/icons/star.png"];
    Draw.Mesh(small, faces, player.badge);
}
  • A Texture is a value (see A Texture is a value): a draw's texture in 2D and 3D, the source a frag's Texture parameter is given, a Draw.Blit's source, something a field or a local holds and a function takes. A frag samples one by its name too: hero.Sample(uv).
  • Its exact fallback keeps a byte per channel: a quarter of what the same image takes as a Grid2<Color>. Eligible color images also have a smaller compressed GPU copy, described below.
  • No code reads it. It has no cells and no size to ask. An image whose pixels or size the game needs is a Grid2<Color>, which draws as a Texture too, and one file can be declared both ways.
  • It's this machine's, like everything that's drawn: views, local handlers and frags use it, and match code can't name one. The match reads a Grid2<Color>.
  • What a field keeps of hero or of run[2] is that image as the files have it now: under tide run, a saved picture shows through the copies a game kept, as it does through hero itself.
  • How it's sampled can be said once, on its declaration: [Filter(Filter.Point), Wrap(Wrap.Repeat)] before the asset (see Saying it once, on the texture). The Texture carries it: what a field keeps of hero is sampled as hero is.

GPU compression

Ordinary color Texture assets are compressed automatically during the build when both dimensions are powers of two and at least 128 pixels. Opaque images use BC1; images with transparency use BC3. This includes images in asset lists and maps. Compression is lossy, so sampled colors can differ slightly from the source, especially around sharp color changes or faint transparency.

On supported GPUs, a full mip chain takes roughly one eighth of RGBA memory for BC1, or one quarter for BC3. Mips are prepared during the build; Filter.Trilinear reads them, while Point and Bilinear retain their existing sampling behavior. Vulkan uses BC where the device has it, OpenGL and WebGL use S3TC when available, and WebGPU uses its optional BC feature. Unsupported devices use the exact RGBA fallback.

The pack currently includes both representations, so this reduces GPU memory and upload bytes while increasing pack size. Small images, non-power-of-two images, cubemaps, grids and render targets keep their current representation. [Raw] data textures remain uncompressed and preserve their channels.

The renderer keeps recently hidden pack images in a bounded GPU cache, avoiding another upload when they return soon. This is whole-image residency; mip streaming and virtual textures aren't built yet.

Image formats

PNG supports its standard color types and bit depths, palette/key transparency and Adam7 interlacing. PNG metadata, color profiles and gamma aren't applied; an APNG supplies its default image, without animation. PSD retains the saved composite described below. All image formats have a maximum of 16,384 pixels per side.

JPEG, TGA and BMP are decoded during the build, through the same Texture, Grid2<Color> and cubemap declarations. Only their pixels enter the pack, so the game needs none of these source decoders. JPEG is lossy and has no alpha; use PNG, PSD or TGA for transparent art. Imported textures keep eight bits per channel, including when the source has more. Color profiles and JPEG EXIF orientation aren't applied; export sRGB pixels in the intended orientation.

Format Accepted variants
JPEG 8-bit baseline/sequential or progressive, grayscale, RGB/YCbCr or Adobe-marked CMYK/YCCK converted to RGB; no arithmetic coding or 12-bit channels
TGA Non-interleaved raw/RLE RGB, grayscale or indexed images, with either vertical/horizontal origin; use 32-bit RGBA for pixel alpha
BMP 1/4/8/16/24/32-bit uncompressed pixels or RGB bitfields; no RLE or embedded JPEG/PNG

JPEG/TGA/BMP source data and combined decoder/output allocations are bounded to 512 MiB. Larger images get an error asking for smaller dimensions.

Photoshop images

PSD import needs no Photoshop installation, including when CI builds the game:

tide
asset Texture portrait = "art/portrait.psd";
asset Grid2<Color> mask = "art/mask.psd";
asset List<Texture> portraits = "art/portraits/*.psd";

Tide reads the composite image saved in the PSD: the visible layers combined into one picture. Save with Photoshop's Maximize Compatibility enabled so the file includes it. Tide doesn't read individual layers or render their effects, text or smart objects itself; their saved appearance is in that composite.

The file must be an ordinary PSD (version 1) in RGB mode, with 8 or 16 bits per channel and at most 16,384 pixels per side. Uncompressed, RLE, ZIP and ZIP with prediction are supported. PSB files, other color modes such as CMYK, and 32-bit channels are errors at the asset's path, as is a missing or damaged composite. A Texture keeps a byte per channel, while a Grid2<Color> keeps each decoded channel as a float. Color profiles aren't applied; save RGB art as sRGB for matching display colors.

Only the decoded pixels are packed, as with a PNG. The game needs no Photoshop files or decoder when it runs. grid.Decode(bytes) still reads PNG only.

Cubemaps

A sky or a room's surroundings is six images, the faces of a cube, which a frag reads by a direction (see Skybox and reflections). That's a Cubemap, and its path is a glob that matches the six files:

tide
asset Cubemap sky = "art/sky_??.png";
  • Each file is named for its face by how its name ends before the extension: px and nx are the faces +x and -x point at, py and ny the ones above and below, pz and nz the ones ahead and behind. sky_px.png, px.png and dusk-PX.png are all the first.
  • Each is a face as it's seen from the cube's middle, the way a picture is: +y is up for the four around, with +x to the right of +z. The one above has -z at its top, and the one below +z.
  • Each uses a supported image format, is square, and all six are one size. A file that's none of the six, a face that's missing or there twice, and a size that differs are errors at the path, which name the file.
  • A file is in the pack once, as any is: a face that's also an asset Texture shares its pixels.
  • It's a value, like a Texture: a field or a local holds one, a function takes one, and a frag samples one by its name, sky.Sample(direction). It's this machine's, so match code can't name one.
  • No list or map of cubes yet, and no [Filter] on one: a cube's filter is said where it's sampled.

Meshes

To keep a static model selection in a component, use a checked asset reference:

tide
asset Mesh<Vertex3> rock = "art/rock.glb";
component Model { Asset<Mesh<Vertex3>> model = rock; }
view Render(Model model, World world)
{
    Draw.Mesh(model.model.Value, world.LocalToWorld(this));
}

Assign a matching singular asset name in code or a prefab override. .Value reads the currently loaded mesh; the stored reference survives geometry reloads and world snapshots. default reads an empty mesh. The compiler rejects missing assets and mismatched vertex types. A borrowed mesh variable or a collection element cannot become a persistent reference in this first version. The native editor can browse, assign and preview these fields; your views still choose materials and drawing.

A model an artist made, or one a tool generated, is a mesh file: glTF (a .glb, or a .gltf with its buffers), FBX (.fbx, binary or ASCII) or a Wavefront .obj. asset Mesh<V> declares one as vertices of a struct of yours, V, which the file fills by its fields' names:

tide
struct Corner
{
    float3 position;
    float3 normal;
    Color color = Color.white;
}

asset Mesh<Corner> tree = "art/tree.glb";
asset List<Mesh<Corner>> rocks = "art/rocks/*.glb";         // In order of their paths
asset Map<string, Mesh<Corner>> props = "art/props/*.glb";  // By them: props["art/props/bench.glb"]

frag Look(Corner corner, out Color color, Instance instance, Sun sun)
{
    color = corner.color * Math.Max(Math.Dot(instance.Normal(corner.normal), -sun.direction), 0.2);
    color.a = 1;
}

view Trees(Tree at)
{
    Draw.Mesh(tree, float4x4.Translate(at.position), Look);
}

The rocket below is a .glb as a modelling tool exports one: five meshes with a material's color each, placed by the file's nodes, and no normals. Here it's one mesh, and a frag lights it from the normal each triangle was given.

A model from a fileLive Tide example, tap it to play
Open separately
tide
settings { title = "A model from a file"; }

local scene Main { }

// What the file fills, by these names: where each corner is, which way its
// face looks, and the color of the part it's in.
struct Corner
{
    float3 position;
    float3 normal;
    Color color;
}

// A glTF: five meshes in it, a color each, placed by its nodes. Here it's one mesh.
asset Mesh<Corner> rocket = "art/rocket.glb";

local singleton Stand
{
    float turn = 2.4;
    float lightAngle = 0.8;
    bool spin = true;
}

// The engine has no lighting, so this is ours: the file's normal against a light.
frag Lit(Corner corner, out Color color, Instance instance, Stand stand)
{
    float3 normal = instance.Normal(corner.normal);
    float3 toLight = Math.Normalize(float3(Math.Cos(stand.lightAngle), 0.6, -Math.Sin(stand.lightAngle)));
    color = corner.color * (0.25 + 0.75 * Math.Max(Math.Dot(normal, toLight), 0));
    color.a = 1;
}

view Show(mut Stand stand)
{
    if (stand.spin) stand.turn += Math.Min(Frame.dt, 0.05) * 0.6;
    if (stand.turn > Math.TAU) stand.turn -= Math.TAU;

    Draw.Clear(Color(0.086, 0.086, 0.094));
    Draw.SetCamera(float3(0, 0.7, -7), quaternion.identity, 45);
    var turned = quaternion.AxisAngle(float3(0, 1, 0), stand.turn);
    // The mesh where its vertices and its indices would go
    Draw.Mesh(rocket, float4x4.TRS(float3(0, 1.2, 0), turned, float3(0.85)), Lit);

    GUI.SetTheme(GUITheme { radius = 6 });
    GUILayout.Area(Rect(12, Screen.height - 124, Screen.width - 24, 112))
    {
        // Its two lists are there to read, like any list
        GUILayout.Label(rocket.vertices.count + " vertices, " + rocket.indices.count / 3 + " triangles");
        GUILayout.Slider("Light", stand.lightAngle, 0, Math.TAU);
        GUILayout.Toggle("Spin", stand.spin);
    }
}

A draw takes it where its vertices and its indices go. Draw.Mesh(tree, transform, ...) and Draw.Instanced(tree, instances, ...), a render target's too, with whatever follows a mesh's lists after it: a texture, a vert, a frag, values. A view that runs for each of a thousand trees draws them all at once, and the mesh is sent to the GPU once, however often it's drawn.

The file fills the struct by its fields' names, each a type the file's values are:

Field Type What the file gives it
position float3 Where the vertex is. Every mesh's vertex has one.
normal float3 Which way the surface faces there. A file with none gets each triangle's own, so it's lit flat.
uv float2 Where in a texture the vertex is, (0, 0) at its top left. (0, 0) where the file has none.
uv2 float2 The file's second set of those, which a lightmap uses.
color Color The vertex's color times its material's. White where the file has neither.
tangent float4 Along the surface, for normal maps: xyz, and in w the side the bitangent is on, 1 or -1. Zero where the file has none.
  • A struct has the ones it needs, in any order: Corner above takes no uvs, so the pack holds none.
  • A field of any other name keeps its default. So one struct serves the meshes a game makes in code and the ones it loads: float glow = 1; is 1 at every vertex of a file's. A default can read another asset, even one declared later; each pack load supplies its current value and refreshes what is drawn when that value changes.
  • A field with one of those names and another type is an error at the declaration, which says the type the file's is.
  • The built-in Vertex3 is a position, a uv and a color: asset Mesh<Vertex3> crate = "art/crate.glb"; draws with no frag, Draw.Mesh(crate, transform, wood).

It's two lists. tree.vertices is a List<Corner> and tree.indices a List<int>, three for each triangle, and Draw.Mesh(tree.vertices, tree.indices, transform, Look) is the same draw. Any code reads them, the match too, since every machine has the same pack, to the bit: a system can build a collider from a rock's own triangles. They're read-only, like every asset; copy one to change it, mut var mine = tree.vertices;.

One file is one mesh. Everything the file's scene places is in it, where its nodes place it, as one list of vertices and one of triangles, with each part's material color in its vertices' color. Only vertices its triangles use are kept; a glTF primitive that shares a larger vertex array with other materials keeps its own part of that array. That's all a model in flat colors needs. What a game moves apart, a car's wheels from its body, is a file each, and a glob names them: asset Map<string, Mesh<Corner>> car = "art/car/*.glb";.

What's read, and what isn't:

  • From a glTF: its meshes' positions, normals, tangents, two sets of uvs and vertex colors, its nodes' places, and each material's base color. Not its textures, nor the rest of a material; not skins, animation, morph targets, cameras or lights: a skinned model is the mesh as it stands. Required Draco/meshopt compression and other unsupported required extensions give an error with export guidance. Optional compression can use the file's valid uncompressed fallback.
  • From an OBJ: positions, uvs, normals and faces of any number of corners, with a color after a position where a tool wrote one. Not its materials file.
  • From an FBX: polygon meshes, node transforms, normals, tangents, two sets of uvs, vertex colors and material base colors. Polygons are triangulated, and the file's axes and units are converted to Y-up meters before Tide's coordinate conversion. Embedded textures and the rest of a material are not imported. A static Mesh<V> takes the rest geometry; use SkinnedMesh<V> for bones and animation.
  • A texture a model has is a file of its own, using a supported image format, declared like any: asset Texture bark = "art/bark.png";, and given to the draw.
  • A mesh holds 16,777,216 vertices at the most.

It comes in as Tide has the world. Tide's world is left-handed with fronts clockwise, like Unity's. glTF is right-handed with fronts counterclockwise: a file's x is turned around and its triangles are wound the other way, so +y stays up and a model that faces +z still faces +z. OBJ uses the same conversion. FBX first converts its declared axes and units to right-handed Y-up meters, then uses that conversion too. A glTF's colors are light, and a Color is what the screen shows, so they're converted; an OBJ's are taken as written.

Nothing keeps one. A Mesh is the pack's own memory, which code reads by the asset's name: a local, a function's parameter and what a function returns pass one on, and no field holds one, nor a task while it waits. What says which model a thing has is a number or a key of yours:

tide
component Prop
{
    float3 position;
    int model; // Its place in `rocks`
}

view Props(Prop prop)
{
    Draw.Mesh(rocks[prop.model], float4x4.Translate(prop.position), Look);
}

A frag and a vert don't read a mesh's two lists: the draw sends them to the GPU as the mesh they are.

Levels of detail

A model with thousands of triangles costs the same to draw when it's a dot on the horizon as when it fills the screen. [LOD] before a mesh asset has simpler versions of it made as the game is built, and each draw then picks one by how big the mesh is on screen:

tide
[LOD] asset Mesh<Corner> rock = "art/rock.glb";

view Rocks(Scenery scenery)
{
    Draw.Instanced(rock, scenery.transforms, Look); // Each rock at its own level
}

That's all there is to write. There are no distances to set, no levels to author and nothing more to pass a draw.

  • It's opt-in. A mesh without [LOD] is drawn as it always was, with nothing made for it.
  • A level is fewer of the mesh's own triangles. Each has about half the triangles of the one before, over the same vertices: none is added or moved, so the pack grows by the levels' triangles alone, about as much again as the mesh's own. Open edges stay where they are, and so do the seams where a texture or a color changes, so no hole opens and no texture tears.
  • No level has fewer than 500 triangles. A level only pays by the triangles it takes off a draw, and picking one costs a little for each instance. So a chain stops before it gets that small, and a mesh of under 1,000 triangles gets no levels at all: it draws exactly as if it weren't marked, with nothing picked.
  • A draw picks the simplest level that looks the same. Each level knows how far its surface is from the full mesh's. A draw takes the simplest one whose difference is under a pixel where the mesh is, so a mesh drawn large keeps all its detail at any distance.
  • Each instance picks its own. Draw.Instanced over a field of rocks draws the near ones whole and the far ones simple: one GPU draw for each level in use, the instances of each in the order the list has them. A view that runs for each entity is grouped the same way.
  • Your code reads the full mesh. rock.vertices and rock.indices are what they are without [LOD], for the match too: a collider built from a rock's triangles is built from all of them, on every machine.
  • It works on a SkinnedMesh<V> too: [LOD] asset SkinnedMesh<Corner> hero = "art/hero.glb";. Its levels are made from the model at rest, weighing how each vertex follows its bones so joints keep the vertices that bend them, and a pose deforms a level as it deforms the full mesh.
  • A list or a map of meshes takes it for every file: [LOD] asset List<Mesh<Corner>> rocks = "art/rocks/*.glb";.
  • They're made once. tide keeps a mesh's levels in the game's .tide/ folder, by what's in the file, so later builds don't make them again.

A game's vert isn't known to the pick. Where the mesh is and how big it is come from the file's own geometry and the draw's transform. A vert that moves vertices far from there, or grows the mesh, gets levels picked for where the mesh would have been: draw such a mesh with Draw.LOD(0), below, or leave [LOD] off it.

Draw.LOD names the level instead of picking it, a draw setting with the two forms the others have. 0 is the full mesh, 1 the first simpler one, and a number past a mesh's last level is its last:

tide
Draw.LOD(0)
{
    Draw.Mesh(rock, transform, Look); // Whole, wherever it is
}

With no number, Draw.LOD() { ... } and Draw.SetLOD();, each mesh picks again. Every instance of an instanced draw has the level that's named, so the draw is one GPU draw in the list's order, which is what instances drawn with a blend and no depth need.

Draw.LODCamera names the camera to pick through, in place of the one the meshes are drawn through. A shadow map picks with its lamp's camera and its own pixels, so a mesh could cast the shadow of another level than the one the player sees. Told the player's camera, it casts what's shown:

tide
view Cast(Lamp lamp, Viewer viewer, Rock rock)
{
    lamp.shadow.SetCamera(lamp.transform, lamp.projection);                 // What the shadow is drawn through
    lamp.shadow.SetLODCamera(viewer.position, viewer.rotation, 60);         // What picks its levels
    lamp.shadow.Mesh(rocks[rock.model], float4x4.Translate(rock.position), Depth);
}

It takes what Draw.Camera takes for a 3D camera: a position, a rotation and a field of view, or a transform and a projection (Draw.CameraTransform() and Draw.CameraProjection() give the window's). With nothing, SetLODCamera(), the destination's own camera picks again. A level that's named wins over a camera that's named.

Info

That a mesh opts in with [LOD], that everything after it is automatic, that each instance picks its own, that skinned meshes have it, and that a game can name a level or a camera to pick by, are decided. How the levels are made, the pixel a level may be off by, the two settings' names and each rule above are how it works today, and may still change. A way to say how many pixels a level may be off by, for a weak machine, isn't in the language yet.

Paths

A path is relative to the .tide file that names it, whatever that file's namespace: there's no assets folder and no root. A package's files name files beside its own code the same way. Forward slashes, on every platform.

A path is a constant: a string literal, or a const string, so a game that wants one path twice names it once:

tide
const string TILES = "art/tiles.png";
asset Grid2<Color> tiles = TILES;

Globs

A glob names every file it matches, in order of their paths (byte by byte): * matches within a name, ** across folders, and ? one character. The type says what you get:

tide
asset List<Grid2<Color>> run = "art/run/*.png";              // Their contents, in order: run[0], run[1], ...
asset Map<string, Grid2<Color>> tiles = "art/tiles/**/*.png"; // Each path as written from this file, with its contents
asset List<string> notes = "notes/*.txt";
asset List<Texture> frames = "art/walk/*.png";              // Images to draw with, in order

A map's keys are the paths as you'd have written them, "art/tiles/grass.png", so a game finds a file by name: tiles["art/tiles/grass.png"], tiles.Has(name). A glob that matches nothing is an error, and so is a glob for a type that holds one file.

A list or a map can name one file, and is then a list or a map of that one:

tide
asset List<Clip> shots = "sfx/shot.wav";             // shots.count is 1, and shots[0] is the file
asset Map<string, Clip> named = "sfx/shot.wav";      // named["sfx/shot.wav"]

That's for a path that changes while the game is made. Start with the one file there is, widen the path to "sfx/shot*.wav" or "sfx/*.wav" when there are more, or narrow it again, and the type and the code that reads it stay as they are. A map's key for the one file is its path as written, which is the key a glob gives it. A URL in a list or a map is the same: one entry, keyed by the URL.

A frag samples an asset by its own name: it can't index a list of them (run[1].Sample(uv) is an error), so declare the one it samples as an asset of its own, or for a Texture, give the draw that one as the frag's source: Draw.Mesh(corners, faces, frames[1], Shade).

URLs

A path can be an http:// or https:// URL. The compiler fetches it as the game is built and packs what it got, so it's a file kept elsewhere and nothing else: nothing is fetched when the game runs, and the game plays offline.

tide
asset string credits = "https://example.com/credits.txt";

What was fetched is kept in the game's .tide/, with the server's ETag. Each build asks the server whether the file changed, and builds from what it has when the server can't be reached, so an offline build works once the file was fetched once. A URL that can't be fetched and was never cached is an error.

What's checked

Anything wrong that can be known as the game is built is an error at the declaration, in the editor as you type and in tide build alike:

  • A file that isn't there. The error says where a file with the same bytes is now, if the file was moved (the compiler keeps a hash of every named file in the game's .tide/), or else lists the files of that name, with a quick fix for each in the editor.
  • A glob that matches nothing.
  • Bytes that aren't what the type says: an unsupported image/audio/model format, a damaged supported file, an unsupported codec or required extension, or text that isn't UTF-8. The diagnostic names the source file and the reason, with supported alternatives or export guidance.
  • A type an asset can't be, a path that isn't a constant, a local or virtual asset, a write to one.

The editor keeps assets in step with their files: renaming a file or a folder rewrites the paths that name it, and a path's completion offers the files it can lead to. See Editors.

The pack

tide build packs the closure of the game's asset declarations, and nothing else in the folder. A file is in the pack once however many declarations name it, and they all read the same value: a Grid2<Color> of the pack lives in a read-only region every world can name, outside every world's heap, so no world copies, hashes or sends it. A file declared as a Grid2<Color> and as a Texture is in it once as each, and so is a text file with characters past ASCII that's declared as a string and as a List<byte>.

Where the pack is: beside the program as <name>.pack on desktop, inside the self-contained page on the web, and in the APK on Android. Under tide run, tide watches the files assets name. Ordinary images and static models reload as data. Skinned models and animation catalogs and assets their vertex defaults read require a matching game build because their values can become match state; the running game refuses a mismatched pack and keeps the current one. This also applies to asset dependencies of vertex defaults used by runtime skinned decoding. A C host of its own (tide_add_game with HOST) gets the pack as a C array, tide_game_pack, to hand tide_assets_load.

Loading at runtime

Skinned models import as SkinnedMesh<V> from glTF, with checked animation and bone catalogs. They can also be loaded while playing with SkinnedMesh<V>.Decode(bytes) or its optional contracted form; runtime decoding accepts self-contained GLB bytes and fails with IOError.InvalidData for malformed data or unmet requirements.

Two things bring bytes in while a game runs, both local, both failing with IOError:

  • File.ReadAllBytes(path) reads a file of this machine's (see Files and directories).
  • await Web.Fetch(url) fetches an http:// or https:// URL, in a task, so frames go on meanwhile.

Bytes that every player should see go into the match through a command, and the match keeps them as its own state, which the netcode sends like any other, to those in the match and to whoever joins.

Decoding a PNG

grid.Decode(bytes) reads a PNG into a Grid2<Color> field of a component, singleton or scene, sizing the grid as the image is and writing every pixel. It fails with IOError.InvalidData for bytes that aren't a PNG, or are wrong anywhere, and then the grid is as it was.

It's pure, so it runs in match code as well as local code: a handler that decodes gives every machine the same pixels, bit for bit, because the engine's own reader does the decoding, with no system library in the way. The grid then draws as a texture like any Grid2<Color> (see Meshes and textures).

A player's own picture, decoded for this machine alone:

tide
local singleton Picker
{
    string path;
    bool load;
    Grid2<Color> preview;
    string message;
}

view Pick(mut Picker picker)
{
    if (!picker.load) return;
    picker.load = false;
    var bytes = File.ReadAllBytes(picker.path) ?? [];
    if (picker.preview.Decode(bytes) is IOError error) picker.message = "Not a PNG: " + error;
    else picker.message = "Loaded " + picker.preview.size.x + " by " + picker.preview.size.y;
}

In the match, the handler of a command decodes, and every machine gets the same pixels:

tide
singleton Arena
{
    Grid2<Color> background;
    PlayerID host;
}

command SetBackground { List<byte> png; }

command(SetBackground value, PlayerID sender) Apply(mut Arena arena)
{
    if (sender != arena.host) return;
    arena.background.Decode(value.png)!;
}

A command's List<byte> goes to the server and on to every player, one that doesn't fit a datagram in chunks, so a few megabytes is fine; see Commands.

Every kind of PNG decodes: gray, color, with a palette, with or without alpha, 1 to 16 bits a sample, interlaced or not. A clear black pixel is a cell that's zero, which takes no memory. A picture past 16384 pixels a side is InvalidData. Decoding runs where it's called, on the frame's or the tick's thread: a 4K picture takes a frame, so decode once, when the picture arrives, not every frame.

Fetching a URL

await Web.Fetch(url) gives the body as a List<byte>. It's local only, like File: views, local handlers, local tasks and the functions they call, never the match, input sampling, the GPU, a sort comparison or a parallel loop. The task waits while this machine fetches, and the bytes arrive in a frame to come.

Result When
IOError.NotFound The server answered 404.
IOError.InvalidPath The URL doesn't start with http:// or https://.
IOError.Failed Anything else: no connection, a certificate the system doesn't trust, another status, a body over 64 MiB, more than 5 redirects, or on the web a server whose CORS refuses the page.
tide
local singleton Download { string status; Grid2<Color> preview; }

async void Fetch(string url, mut Download download)
{
    download.status = "fetching";
    if (await Web.Fetch(url) is IOError error)
    {
        download.status = "failed: " + error;
        return;
    }
    download.status = "fetched";
}

Big media is better fetched by each machine than sent through the match: what goes through the match goes player to server to everyone, so a host uploads it once per player. Keep a URL in the match and let each machine fetch and decode.