There are many posts that’ll tell you the “hello world” of calling native code like C++ from a C# Unity project. These tend to be simple examples though, so it’s hard to go from that to wrapping a real life useful native library. Today we’ll wrap SQLite, a popular C library that implements a database, and talk about the challenges in doing so and how to end up with a pleasant C# API for the rest of the game to use. Read on to learn how!

Getting the Library

First thing’s first, we need to get the library to wrap. Since we’re wrapping SQLite today, we’ll head over to their download page and acquire their recommended amalgamation ZIP file. This contains the whole SQLite library in just three huge source files.

Next, we’ll build the library. Since SQLite’s amalgamation contains the entire library in a single source file, we can simply run one command to build a shared library. Here’s the one command to build it on macOS:

clang -shared -undefined dynamic_lookup -o sqlite3.so sqlite3.c

Now we can drop the shared library (sqlite3.so) into our Unity project and click on it in the editor’s Project pane to see its properties in the Inspector pane. Uncheck Any Platform and check Editor and Standalone instead. The change the CPU to x86_64 and OS to OSX in the Editor Settings tab. In the PC, Mac & Linux Standalone Settings tab, uncheck the Linux checkboxes and check the Mac OS X one. Finally, click the Apply button.

Now we’re done with the GUI and can focus on the code.

P/Invoke Setup

Create a new C# file for the wrapper: Sqlite.cs. Inside, put a static Sqlite class with a nested static PInvoke class like so:

public static class Sqlite
{
    private static class PInvoke
    {
#if UNITY_IOS
        const string PLUGIN_NAME = "__Internal";
#else
        const string PLUGIN_NAME = "sqlite3";
#endif
    }
}

This forms the basis of the API. Users of this interface will call functions on Sqlite and those functions will in turn call functions in PInvoke to communicate with the native library. Adding the extra level of indirection allows for Sqlite to massage the native API into a more pleasant, idiomatic C# style as we’ll see shortly.

Next, let’s wrap our first native function: sqlite3_open. This opens a database file or in-RAM database so that SQL queries can be run on it. Here’s the function’s signature in C:

int sqlite3_open(const char* filename, sqlite3** ppDb);

We can allow C# to call this function by putting a method with the same name in the PInvoke class:

[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern ReturnCode sqlite3_open(string filename, out IntPtr ppDb);

Notice that we’ve changed all three types here. First, filename is a C-string which P/Invoke knows how to map to directly from a string so it’s safe to change the type to that.

Second, ppDb is a pointer to a pointer to a sqlite3. We can replace the first level of pointer with IntPtr since it is by definition the size of a native pointer. Then we can get the second level of pointer as well as the semantics that it’ll be assigned by the function simply by adding the out keyword.

Third, we’ve changed the return type from int to ReturnCode. This is an enum whose underlying type is int and adds a little meaning to the return value. Here’s how we define it in the Sqlite class:

public enum ReturnCode : int
{
    OK = 0,
    ROW = 100,
    DONE = 101,
}

SQLite has many more return codes, but these will suffice for today.

Next we add the indirection layer in SQLite to call the sqlite3_open in PInvoke:

public static ReturnCode Open(string filename, out Pointer<Database> db)
{
    IntPtr pDb;
    ReturnCode rc = PInvoke.sqlite3_open(filename, out pDb);
    db = new Pointer<Database>(pDb);
    return rc;
}

This function adds a few tricks on top of just calling sqlite3_open. First, it uses a Database type to stand in for the native sqlite3 type. This is a more idiomatic C# way of naming structures, so usage of the interface will be a little more familiar to C# games. Since we’re just dealing with pointers to one, we don’t really care how it’s defined so let’s just leave it empty:

public struct Database
{
}

Next, we use a Pointer<Database> to make a strongly-typed pointer to a Database. We could have just used an IntPtr but that would allow for errors to occur when accidentally passing a pointer to something else. Strong typing costs us nothing here as Pointer is just a struct containing an IntPtr:

public struct Pointer<T>
{
    public IntPtr Ptr;
 
    public Pointer(IntPtr ptr)
    {
        Ptr = ptr;
    }
}

To put it all together, we now have a file that looks like this:

using System;
using System.Runtime.InteropServices;
 
public static class Sqlite
{
    public struct Database
    {
    }
 
    public struct Pointer<T>
    {
        public IntPtr Ptr;
 
        public Pointer(IntPtr ptr)
        {
            Ptr = ptr;
        }
    }
 
    public enum ReturnCode : int
    {
        OK = 0,
        ROW = 100,
        DONE = 101,
    }
 
    public static ReturnCode Open(string filename, out Pointer<Database> db)
    {
        IntPtr pDb;
        ReturnCode rc = PInvoke.sqlite3_open(filename, out pDb);
        db = new Pointer<Database>(pDb);
        return rc;
    }
 
    private static class PInvoke
    {
#if UNITY_IOS
        const string PLUGIN_NAME = "__Internal";
#else
        const string PLUGIN_NAME = "sqlite3";
#endif
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern ReturnCode sqlite3_open(
            string filename,
            out IntPtr ppDb);
    }
}

The rest of the game can use it like this:

Sqlite.Pointer<Sqlite.Database> db;
Sqlite.ReturnCode rc = Sqlite.Open("/path/to/highscores.db", out db);
if (rc != Sqlite.ReturnCode.OK)
{
    print("Error opening DB: " + rc);
    return;
}
 
// ... use DB
More Methods

The rest of the wrapper will follow the pattern of sqlite3_open. We’ll walk through seven more functions to see how the wrapper takes shape. First up is sqlite3_close that allows us to close the database we just opened. Here’s how the C signature looks:

int sqlite3_close(sqlite3* pDb);

Here’s how our PInvoke wrapper looks:

[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern ReturnCode sqlite3_close(IntPtr pDb);

We use the same trick of replacing the int return value with a ReturnCode to get strong typing and enumerated values. Since the parameter is just a pointer, we omit the out and just take an IntPtr. Now let's look at theSqlite` indirection method:

public static ReturnCode Close(Pointer<Database> db)
{
    return PInvoke.sqlite3_close(db.Ptr);
}

This is really just a trivial pass-through of the pointer contained in the Pointer<Database>, so let’s move on to a more interesting function: sqlite3_prepare_v2. This prepares a SQL statement for execution. It’s C signature looks like this:

int sqlite3_prepare_v2(
    sqlite3* db,
    const char* zSql,
    int nByte,
    sqlite3_stmt** ppStmt,
    const char** pzTail);

Like sqlite3_close, this takes a pointer to the database. It then takes a SQL statement string and number of bytes long that string is. Then it takes an output parameter like sqlite3_open did, but this time with a new opaque type: sqlite3_stmt. Finally, it takes another output parameter for the last character read of the given SQL statement string.

Here’s the C# wrapper that goes in PInvoke:

[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern ReturnCode sqlite3_prepare_v2(
    IntPtr pDb,
    string sql,
    int sqlLength,
    out IntPtr ppStmt,
    IntPtr pzTail);

Once again we’ve placed the int return value with ReturnCode, all the pointers with IntPtr, and the string with string. The interesting bit is next up in the Sqlite indirection layer:

public static ReturnCode Prepare(
    Pointer<Database> db,
    string sql,
    out Pointer<Statement> statement)
{
    IntPtr ptr;
    ReturnCode rc = PInvoke.sqlite3_prepare_v2(
        db.Ptr,
        sql,
        -1,
        out ptr,
        IntPtr.Zero);
    statement = new Pointer<Statement>(ptr);
    return rc;
}

Here we take a strongly-typed Pointer<Statement> where Statement is another empty struct:

public struct Statement
{
}

However, notice that the parameters to Prepare don’t quite match the parameters to sqlite3_prepare_v2. We can take fewer parameters because we’re going to pass constants for some of them anyhow. In this case, we pass -1 for the length of the SQL string to indicate that the whole thing should be read. Then we pass IntPtr.Zero, equivalent to null, for the last parameter to indicate that we’re not interested in receiving a pointer to the last character of the SQL string. The result is a cleaner interface for the game that includes just what’s really needed. The indirection layer makes this possible.

The next two functions are extremely straightforward: sqlite3_step and sqlite3_finalize. Calling sqlite3_step steps through the results of a SQL statement created by sqlite3_prepare_v2 and sqlite3_finalize finalizes that statement. Here are there C signatures:

int sqlite3_step(sqlite3_stmt* pStmt);
int sqlite3_finalize(sqlite3_stmt* pStmt);

Here’s how we wrap them in PInvoke:

[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern ReturnCode sqlite3_step(IntPtr stmt);
 
[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern ReturnCode sqlite3_finalize(IntPtr stmt);

And here’s the indirection layer:

public static ReturnCode Step(Pointer<Statement> statement)
{
    return PInvoke.sqlite3_step(statement.Ptr);
}
 
public static ReturnCode Finalize(Pointer<Statement> statement)
{
    return PInvoke.sqlite3_finalize(statement.Ptr);
}

Now for a more interesting function: sqlite3_exec. This function performs all three of these steps in one shot: sqlite3_prepare_v2 then sqlite3_step then sqlite3_finalize. It’s a convenient function to reduce verbosity, especially when there are no results to step through such as a DROP statement. Here’s how the C signature looks:

int sqlite3_exec(
    sqlite3* pDb,
    const char* sql,
    int (*callback)(void*, int, char**, char**),
    void* userdata,
    char** errmsg);

Like sqlite3_prepare_v2, this function takes the database and SQL query string. It doesn’t take a length of that string nor an optional out parameter for the parsed end of it. Instead, it takes a pointer to a callback function to execute for each row in the results and a userdata pointer to pass to that callback function. It also takes an out parameter for any error message. Here’s how we’ll wrap it:

[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern ReturnCode sqlite3_exec(
    IntPtr pDb,
    string sql,
    IntPtr callback,
    IntPtr userdata,
    IntPtr errmsg);

The wrapping is very straightforward as we simply replace the SQL string with string, all the pointers with IntPtr, and the return int with ReturnCode. The interesting part comes in the indirection layer in Sqlite:

public static ReturnCode Execute(Pointer<Database> database, string sql)
{
    return PInvoke.sqlite3_exec(
        database.Ptr,
        sql,
        IntPtr.Zero,
        IntPtr.Zero,
        IntPtr.Zero);
}

Like with sqlite3_prepare_v2, we can cut down on the number of parameters the game needs to pass. We’re only going to use this function for SQL statements with no result rows, so we’ll pass IntPtr.Zero for the callback and userdata parameters. We’ll also pass IntPtr.Zero for the error message output since we want to ignore it anyhow. The result is another very simple function for the game to call.

To finish up the wrapper, let’s look at two functions to get columns of a row: sqlite3_column_text and sqlite3_column_int. These get text and 32-bit integer columns, respectively. Here are their C signatures:

const unsigned char* sqlite3_column_text(sqlite3_stmt* pStmt, int iCol);
int sqlite3_column_int(sqlite3_stmt* pStmt, int iCol);

Here’s how we wrap them:

[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_column_text(IntPtr stmt, int iCol);
 
[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_int(IntPtr stmt, int iCol);

Notice that even though sqlite3_column_text returns a string, we don’t use the string return type. This is because we need to perform an additional step in the indirection layer:

public static string GetTextColumn(
    Pointer<Statement> statement,
    int columnIndex)
{
    IntPtr ptr = PInvoke.sqlite3_column_text(statement.Ptr, columnIndex);
    return Marshal.PtrToStringAuto(ptr);
}
 
public static int GetIntColumn(
    Pointer<Statement> statement,
    int columnIndex)
{
    return PInvoke.sqlite3_column_int(statement.Ptr, columnIndex);
}

The string returned by sqlite3_column_text needs to be copied into a managed string. By default, the P/Invoke marshaller won’t make this copy. So we need to capture the string as just a pointer and then call Marshal.PtrToStringAuto to force it to make a copy of the C string into a managed string. The indirection layer gives us the flexibility to make this copy within the interface code so users of it don’t need to worry about the memory management details of the SQLite API.

We’ve now completed our wrapper for today. There’s a lot more of the SQLite API that we could wrap, but we’ve covered a representative portion of it for the purposes of this article. Notice that we didn’t use any “unsafe” code at all in the wrapper, so this is usable without enabling that compiler option or adding any unsafe keywords.

Here is the final Sqlite.cs file. Feel free to use it in your projects as-is, as the basis of a more complete wrapper, or as a wrapper for some other native library.

using System;
using System.Runtime.InteropServices;
 
/// <summary>
/// An interface to SQLite
/// </summary>
/// 
/// <author>
/// Jackson Dunstan, https://JacksonDunstan.com/articles/5117
/// </author>
/// 
/// <license>
/// MIT
/// </license>
public static class Sqlite
{
    /// <summary>
    /// An SQLite database
    /// </summary>
    public struct Database
    {
    }
 
    /// <summary>
    /// An SQLite SQL statement
    /// </summary>
    public struct Statement
    {
    }
 
    /// <summary>
    /// A pointer to an SQLite type
    /// </summary>
    public struct Pointer<T>
    {
        /// <summary>
        /// Pointer to the native memory for the type
        /// </summary>
        public IntPtr Ptr;
 
        /// <summary>
        /// Create the pointer
        /// </summary>
        /// 
        /// <param name="ptr">
        /// Pointer to the native memory for the type
        /// </param>
        public Pointer(IntPtr ptr)
        {
            Ptr = ptr;
        }
    }
 
    public enum ReturnCode : int
    {
        /// <summary>
        /// Everything went OK
        /// </summary>
        OK = 0,
 
        /// <summary>
        /// A row is available
        /// </summary>
        ROW = 100,
 
        /// <summary>
        /// An operation is done
        /// </summary>
        DONE = 101,
    }
 
    /// <summary>
    /// Filename to pass to <see cref="Open"/> to use RAM instead of a file
    /// </summary>
    public const string MemoryFilename = ":memory:";
 
    /// <summary>
    /// Open a database
    /// </summary>
    /// 
    /// <returns>
    /// <see cref="ReturnCode.OK"/> if the DB opened successfully
    /// </returns>
    /// 
    /// <param name="filename">
    /// Filename of the DB to open or <see cref="MemoryFilename"/> to use RAM
    /// </param>
    /// 
    /// <param name="db">
    /// Pointer to the opened database
    /// </param>
    public static ReturnCode Open(string filename, out Pointer<Database> db)
    {
        IntPtr pDb;
        ReturnCode rc = PInvoke.sqlite3_open(filename, out pDb);
        db = new Pointer<Database>(pDb);
        return rc;
    }
 
    /// <summary>
    /// Close a database
    /// </summary>
    /// 
    /// <returns>
    /// <see cref="ReturnCode.OK"/> if the DB closed successfully
    /// </returns>
    /// 
    /// <param name="db">
    /// Database to close
    /// </param>
    public static ReturnCode Close(Pointer<Database> db)
    {
        return PInvoke.sqlite3_close(db.Ptr);
    }
 
    /// <summary>
    /// Prepare a SQL statement. Call <see cref="Step"/> after this until done
    /// and then call <see cref="Finalize"/>.
    /// </summary>
    /// 
    /// <returns>
    /// <see cref="ReturnCode.OK"/> if the statement was successfully prepared
    /// </returns>
    /// 
    /// <param name="db">
    /// DB to prepare the statement for
    /// </param>
    /// 
    /// <param name="sql">
    /// SQL statement to prepare
    /// </param>
    /// 
    /// <param name="statement">
    /// The prepared statement
    /// </param>
    public static ReturnCode Prepare(
        Pointer<Database> db,
        string sql,
        out Pointer<Statement> statement)
    {
        IntPtr ptr;
        ReturnCode rc = PInvoke.sqlite3_prepare_v2(
            db.Ptr,
            sql,
            -1,
            out ptr,
            IntPtr.Zero);
        statement = new Pointer<Statement>(ptr);
        return rc;
    }
 
    /// <summary>
    /// Step the statement to the next result
    /// </summary>
    /// 
    /// <returns>
    /// <see cref="ReturnCode.ROW"/> if stepped to a new result.
    /// <see cref="ReturnCode.DONE"/> if the statement is finished.
    /// </returns>
    /// 
    /// <param name="statement">
    /// Statement to step
    /// </param>
    public static ReturnCode Step(Pointer<Statement> statement)
    {
        return PInvoke.sqlite3_step(statement.Ptr);
    }
 
    /// <summary>
    /// Finalize a statement when done with it
    /// </summary>
    /// 
    /// <returns>
    /// <see cref="ReturnCode.OK"/> if finalizing was successful
    /// </returns>
    /// 
    /// <param name="statement">
    /// Statement to finalize
    /// </param>
    public static ReturnCode Finalize(Pointer<Statement> statement)
    {
        return PInvoke.sqlite3_finalize(statement.Ptr);
    }
 
    /// <summary>
    /// Execute a SQL query
    /// </summary>
    /// 
    /// <returns>
    /// <see cref="ReturnCode.OK"/> if the query was executed successfully
    /// </returns>
    /// 
    /// <param name="database">
    /// Database to execute the query on
    /// </param>
    /// 
    /// <param name="sql">
    /// SQL query to execute
    /// </param>
    public static ReturnCode Execute(Pointer<Database> database, string sql)
    {
        return PInvoke.sqlite3_exec(
            database.Ptr,
            sql,
            IntPtr.Zero,
            IntPtr.Zero,
            IntPtr.Zero);
    }
 
    /// <summary>
    /// Get a text column from the current row of a statement
    /// </summary>
    /// 
    /// <returns>
    /// The text of the given column
    /// </returns>
    /// 
    /// <param name="statement">
    /// Statement whose current row to get the text of
    /// </param>
    /// 
    /// <param name="columnIndex">
    /// Index of the column of the statement's current row to get
    /// </param>
    public static string GetTextColumn(
        Pointer<Statement> statement,
        int columnIndex)
    {
        IntPtr ptr = PInvoke.sqlite3_column_text(statement.Ptr, columnIndex);
        return Marshal.PtrToStringAuto(ptr);
    }
 
    /// <summary>
    /// Get a 32-bit int column from the current row of a statement
    /// </summary>
    /// 
    /// <returns>
    /// The int value of the given column
    /// </returns>
    /// 
    /// <param name="statement">
    /// Statement whose current row to get the int value of
    /// </param>
    /// 
    /// <param name="columnIndex">
    /// Index of the column of the statement's current row to get
    /// </param>
    public static int GetIntColumn(
        Pointer<Statement> statement,
        int columnIndex)
    {
        return PInvoke.sqlite3_column_int(statement.Ptr, columnIndex);
    }
 
    /// <summary>
    /// P/Invoke wrapper around the native library. See SQLite official docs for
    /// more about these functions.
    /// </summary>
    private static class PInvoke
    {
        /// <summary>
        /// Name of the plugin to load
        /// </summary>
#if UNITY_IOS
        const string PLUGIN_NAME = "__Internal";
#else
        const string PLUGIN_NAME = "sqlite3";
#endif
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern ReturnCode sqlite3_open(
            string filename,
            out IntPtr ppDb);
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern ReturnCode sqlite3_close(IntPtr pDb);
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern ReturnCode sqlite3_prepare_v2(
            IntPtr pDb,
            string sql,
            int sqlLength,
            out IntPtr ppStmt,
            IntPtr pzTail);
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern ReturnCode sqlite3_step(IntPtr stmt);
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern ReturnCode sqlite3_finalize(IntPtr stmt);
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern ReturnCode sqlite3_exec(
            IntPtr pDb,
            string sql,
            IntPtr callback,
            IntPtr userdata,
            IntPtr errmsg);
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern IntPtr sqlite3_column_text(IntPtr stmt, int iCol);
 
        [DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
        public static extern int sqlite3_column_int(IntPtr stmt, int iCol);
    }
}
Usage Example

Now that we have the Sqlite wrapper class, let’s try it out in a more complete example:

using System.IO;
using UnityEngine;
 
public class TestScript : MonoBehaviour
{
    void Start()
    {
        // Delete any existing database
        const string path = "/path/to/highscores.db";
        if (File.Exists(path))
        {
            File.Delete(path);
        }
 
        // Open the database
        Sqlite.Pointer<Sqlite.Database> db;
        Sqlite.ReturnCode rc = Sqlite.Open(path, out db);
        if (rc != Sqlite.ReturnCode.OK)
        {
            print("Error opening DB: " + rc);
            return;
        }
 
        // Create the table and insert a few rows
        rc = Sqlite.Execute(
            db,
            "CREATE TABLE HIGHSCORES("
            + "  ID INT PRIMARY KEY NOT NULL,"
            + "  NAME TEXT NOT NULL,"
            + "  SCORE INT NOT NULL);"
            + "INSERT INTO HIGHSCORES(ID, NAME, SCORE)"
            + "  VALUES (1, 'Jackson', 5000);"
            + "INSERT INTO HIGHSCORES(ID, NAME, SCORE)"
            + "  VALUES (2, 'John', 3000);");
        if (rc != Sqlite.ReturnCode.OK)
        {
            Sqlite.Close(db);
            print("Error populating table: " + rc);
            return;
        }
 
        // Start querying the rows
        Sqlite.Pointer<Sqlite.Statement> statement;
        rc = Sqlite.Prepare(
            db,
            "SELECT ID, NAME, SCORE FROM HIGHSCORES;",
            out statement);
        if (rc != Sqlite.ReturnCode.OK)
        {
            Sqlite.Close(db);
            print("Error querying table: " + rc);
            return;
        }
 
        // Loop until we've read all rows
        while (true)
        {
            // Go to the next row
            rc = Sqlite.Step(statement);
            if (rc != Sqlite.ReturnCode.ROW)
            {
                if (rc != Sqlite.ReturnCode.DONE)
                {
                    Sqlite.Close(db);
                    print("Error stepping query: " + rc);
                    return;
                }
                break;
            }
 
            // Get the row's columns and print them
            int id = Sqlite.GetIntColumn(statement, 0);
            string name = Sqlite.GetTextColumn(statement, 1);
            int score = Sqlite.GetIntColumn(statement, 2);
            print("ID: " + id + ", Name: " + name + ", Score: " + score);
        }
 
        // Finalize the query
        rc = Sqlite.Finalize(statement);
        if (rc != Sqlite.ReturnCode.OK)
        {
            Sqlite.Close(db);
            print("Error finalizing query statement: " + rc);
            return;
        }
 
        // Close the database
        rc = Sqlite.Close(db);
        if (rc != Sqlite.ReturnCode.OK)
        {
            print("Error closing DB: " + rc);
            return;
        }
    }
}

Using the wrapper has several nice aspects to it. First, there’s no need to know anything about the underlying native API. None of the function names or types are directly exposed. Some of the parameters are omitted from the functions. All of the functions and types are renamed to match the normal C# naming conventions. Everything is strongly-typed, including pointers and return codes. And no “unsafe” code is required here either as the pointer types are just a struct containing another struct: IntPtr.

Running this little script either in editor, in a standalone build with Mono, or in a standalone build with IL2CPP, we get the same correct result:

ID: 1, Name: Jackson, Score: 5000
ID: 2, Name: John, Score: 3000
Conclusion

Today we’ve wrapped a real-world library without making any modifications to it. We’ve overcome some tricky P/Invoke issues and presented a simple interface for the game to use. It’s even a useful library that, with a little more wrapping following the same formula, could be a real boon to a game project that wants to, for example, go beyond the limits of PlayerPrefs. Hopefully you’ll find this useful as there are many excellent native libraries out there to wrap!