Calling C

A game can call C: a library it ships with, like stb or miniaudio, a platform SDK, or C of its own. extern declares a function written in C, with no body:

tide
extern float Lerp3(float a, float b, float c, float t);

It's called like any function, from systems, views, event handlers, methods and other functions, and the call costs what a call between C functions does:

tide
system Blend(mut Mix mix)
{
    mix.value = Lerp3(mix.a, mix.b, mix.c, mix.t);
}

Names

The C function has the name the extern is written with. When C's name doesn't suit Tide, [NativeName] gives it, and the Tide name can follow Tide's style:

tide
[NativeName("stb_perlin_noise3")]
extern float Noise(float x, float y, float z, int xWrap, int yWrap, int zWrap);

A namespace doesn't change the C name: in namespace Terrain;, that's Terrain.Noise in Tide and still stb_perlin_noise3 in C.

Where the C goes

In the game's folder, with nothing to set up:

  • Every .c file in the folder and its subfolders compiles with the game, with the same determinism flags as the engine. Headers next to them are found as C finds them.
  • Every prebuilt library there links with the game when it was built for the platform being built for: .a and .lib files, and .so, .dll and .dylib ones. tide reads each library to tell which platform and CPU it's for, so one folder holds them all, named and placed however you like.
  • Code for one platform only goes in #ifdef, as in any C.
MyGame/
  game.tide
  noise.c              // #define STB_PERLIN_IMPLEMENTATION, then #include "stb_perlin.h"
  stb_perlin.h
  steam/
    steam_api64.lib    // Windows: links in
    steam_api64.dll    // ...and goes next to the game
    libsteam_api.so    // Linux
    libsteam_api.dylib // macOS
  steam_web.c          // #ifdef __wasm__: stand-ins, as the web has no Steam

.so, .dll and .dylib files are copied next to the built game, where it finds them. On Windows, a .dll links through its import library, the .lib that comes with it.

Windows games build for MinGW, so a static library built with Microsoft's compiler may need Microsoft's C runtime and fail to link: rebuild it with clang or MinGW.

On the web, only C files and WebAssembly libraries define functions. When an extern function has no definition there, the web build fails and names it; give it a stand-in inside #ifdef __wasm__. Web games build for wasm32-wasip1-threads, which shares memory between threads, so a WebAssembly library needs building for that target too (clang's --target=wasm32-wasip1-threads), or at least with -matomics -mbulk-memory. tide's own clang can build it, as tide cc, with the C library tide brings: tide cc --target=wasm32-wasip1-threads --sysroot=<tide>/wasi/sysroot, where <tide> is the folder tide version says it's installed in.

tide run builds again when a C file, header or library changes. The C is part of the game's library, which each build replaces, so whatever C keeps in its own variables starts over at each reload.

Values across

Extern functions take and return plain data, by value:

Tide C
int, float, bool int32_t (int), float, bool
float3, int2, quaternion, float4x4, ... tide_float3, tide_int2, ... from tide/math.h
Color, Rect, Entity, PlayerID tide_color, tide_rect, tide_entity, tide_player_id
an enum int32_t, or uint8_t and uint16_t for : byte and : ushort
a struct or component a struct with the same fields, in the same order

A system that splits its entities across threads gives the entities it spawns temporary handles until it's done (see The schedule), so an Entity C gets from one may be temporary: tide_entity_is_temporary(e), from tide/entity.h, tells. Tide gives the real handle to what the system kept, but not to C, so C that keeps entities should keep real ones.

C often takes pointers. Tide has none, and no pointer arithmetic: the parameter says how a value goes to C, and the call takes its address by itself. Every pointer is only good until C returns.

Parameter C gets
mut T x T *: the caller's variable, which C can change
in T x const T *: the caller's variable, read-only, with no copy (a copy for a value that's no variable's)
List<T> xs const T *: the list's elements, NULL when it's empty. Pass xs.Count too
mut List<T> xs T *: the elements, which C can change, but not how many there are
string s const char *: UTF-8, ending in a zero

A world keeps a list whose elements take more than 16 KB in pieces, so that changing one element doesn't copy the rest. C gets those elements side by side in a copy, made for the call, and with mut, written back into the list once C returns. Smaller lists, and lists that aren't a world's, go to C as they are.

An extern function can return string from C's const char *: the text is copied, so C can reuse its buffer, and NULL is empty text.

tide
extern float Average(List<float> values, int count);
extern string Describe(in Stats stats);

system Report(Scores scores, mut Summary summary)
{
    summary.average = Average(scores.values, scores.values.Count);
    summary.text = Describe(summary.stats);
}
c
float Average(const float *values, int count);
const char *Describe(const Stats *stats);
tide
struct Hit
{
    float3 point;
    float distance;
}

extern bool RayCast(float3 from, float3 direction, mut Hit hit);
c
#include <stdbool.h>
#include "tide/math.h"

typedef struct Hit
{
    tide_float3 point;
    float distance;
} Hit;

bool RayCast(tide_float3 from, tide_float3 direction, Hit *hit)
{
    // ...
    return false;
}

Tide's types have no padding the compiler adds, so a C struct with the same fields lines up with them. Structs that hold text or lists can't go to C, and neither can lists of text or T? values.

C functions can't fail (see Errors): they return what C returns. To turn a C function's error code into an error, check it in a Tide function that fails, and call that.

Order

C can keep state, so calling it is a side effect, and Tide keeps its order: in Pick(Roll(), Roll()), the first Roll runs first on every platform, though C itself would let each compiler pick. The same goes for functions, methods and operators that call C, and for calls inside &&, || and ?:, whose parts still only run when they would.

What C is trusted with

The compiler doesn't look inside C. It takes each call as touching nothing it tracks, so C never makes systems wait for each other, and anything C does is the game's to get right:

  • Determinism. Match code runs the same on every machine only if its C does too: no platform math library (sinf, powf), and nothing that depends on the machine. The game's own C files get the engine's flags, which keep float math exact; a prebuilt library's flags are whatever it was built with.
  • State. Variables C keeps aren't in the world, so they aren't sent, rolled back or hashed. Keep what the match depends on in components and singletons.
  • Threads. Once systems run in parallel, two that call the same C function can run at the same time.

C also changes how players join. When starting a match calls no C (no match event handler does, through anything it calls), a player joining a big world starts the match on its own machine too, and only gets what changed since (see Sending worlds). C called while a match starts could do something outside the world on every joining machine, so a game whose match start calls C sends joining players the whole world instead.

tidec declares each extern function from its Tide signature. If it doesn't match the C function, the call goes wrong the way it would in C.