This code is not made by any PulseAudio developer. It may not be best practice, but it seems to work and could be used as a start for a playback application that uses the async API.

This simple application plays a constant note and uses a low latency setup (20 ms) that should be suitable for games. It also increases the latency when underruns are detected, this makes it possible to get good sounding playback over networks as well.

Save code as e.g. pa-beep.c and build using: gcc $(pkg-config --cflags --libs libpulse) pa-beep.c -o pa-beep

#include <stdio.h>
#include <string.h>
#include <pulse/pulseaudio.h>

static int latency = 20000; // start latency in micro seconds
static int sampleoffs = 0;
static short sampledata[300000];
static pa_buffer_attr bufattr;
static int underflows = 0;
static pa_sample_spec ss;

// This callback gets called when our context changes state.  We really only
// care about when it's ready or if it has failed
void pa_state_cb(pa_context *c, void *userdata) {
  pa_context_state_t state;
  int *pa_ready = userdata;
  state = pa_context_get_state(c);
  switch  (state) {
    // These are just here for reference
  case PA_CONTEXT_UNCONNECTED:
  case PA_CONTEXT_CONNECTING:
  case PA_CONTEXT_AUTHORIZING:
  case PA_CONTEXT_SETTING_NAME:
  default:
    break;
  case PA_CONTEXT_FAILED:
  case PA_CONTEXT_TERMINATED:
    *pa_ready = 2;
    break;
  case PA_CONTEXT_READY:
    *pa_ready = 1;
    break;
  }
}

static void stream_request_cb(pa_stream *s, size_t length, void *userdata) {
  pa_usec_t usec;
  int neg;
  pa_stream_get_latency(s,&usec,&neg);
  printf("  latency %8d us\n",(int)usec);
  if (sampleoffs*2 + length > sizeof(sampledata)) {
    sampleoffs = 0;
  }
  if (length > sizeof(sampledata)) {
    length = sizeof(sampledata);
  }
  pa_stream_write(s, &sampledata[sampleoffs], length, NULL, 0LL, PA_SEEK_RELATIVE);
  sampleoffs += length/2;
}

static void stream_underflow_cb(pa_stream *s, void *userdata) {
  // We increase the latency by 50% if we get 6 underflows and latency is under 2s
  // This is very useful for over the network playback that can't handle low latencies
  printf("underflow\n");
  underflows++;
  if (underflows >= 6 && latency < 2000000) {
    latency = (latency*3)/2;
    bufattr.maxlength = pa_usec_to_bytes(latency,&ss);
    bufattr.tlength = pa_usec_to_bytes(latency,&ss);  
    pa_stream_set_buffer_attr(s, &bufattr, NULL, NULL);
    underflows = 0;
    printf("latency increased to %d\n", latency);
  }
}

int main(int argc, char *argv[]) {
  pa_mainloop *pa_ml;
  pa_mainloop_api *pa_mlapi;
  pa_context *pa_ctx;
  pa_stream *playstream;
  int r;
  int pa_ready = 0;
  int retval = 0;
  unsigned int a;
  double amp;

  // Create some data to play
  for (a=0; a<sizeof(sampledata)/2; a++) {
    amp = cos(5000*(double)a/44100.0);
    sampledata[a] = amp * 32000.0;
  }

  // Create a mainloop API and connection to the default server
  pa_ml = pa_mainloop_new();
  pa_mlapi = pa_mainloop_get_api(pa_ml);
  pa_ctx = pa_context_new(pa_mlapi, "Simple PA test application");
  pa_context_connect(pa_ctx, NULL, 0, NULL);

  // This function defines a callback so the server will tell us it's state.
  // Our callback will wait for the state to be ready.  The callback will
  // modify the variable to 1 so we know when we have a connection and it's
  // ready.
  // If there's an error, the callback will set pa_ready to 2
  pa_context_set_state_callback(pa_ctx, pa_state_cb, &pa_ready);

  // We can't do anything until PA is ready, so just iterate the mainloop
  // and continue
  while (pa_ready == 0) {
    pa_mainloop_iterate(pa_ml, 1, NULL);
  }
  if (pa_ready == 2) {
    retval = -1;
    goto exit;
  }

  ss.rate = 44100;
  ss.channels = 1;
  ss.format = PA_SAMPLE_S16LE;
  playstream = pa_stream_new(pa_ctx, "Playback", &ss, NULL);
  if (!playstream) {
    printf("pa_stream_new failed\n");
  }
  pa_stream_set_write_callback(playstream, stream_request_cb, NULL);
  pa_stream_set_underflow_callback(playstream, stream_underflow_cb, NULL);
  bufattr.fragsize = (uint32_t)-1;
  bufattr.maxlength = pa_usec_to_bytes(latency,&ss);
  bufattr.minreq = pa_usec_to_bytes(0,&ss);
  bufattr.prebuf = (uint32_t)-1;
  bufattr.tlength = pa_usec_to_bytes(latency,&ss);
  r = pa_stream_connect_playback(playstream, NULL, &bufattr,
                                 PA_STREAM_INTERPOLATE_TIMING
                                 |PA_STREAM_ADJUST_LATENCY
                                 |PA_STREAM_AUTO_TIMING_UPDATE, NULL, NULL);
  if (r < 0) {
    // Old pulse audio servers don't like the ADJUST_LATENCY flag, so retry without that
    r = pa_stream_connect_playback(playstream, NULL, &bufattr,
                                   PA_STREAM_INTERPOLATE_TIMING|
                                   PA_STREAM_AUTO_TIMING_UPDATE, NULL, NULL);
  }
  if (r < 0) {
    printf("pa_stream_connect_playback failed\n");
    retval = -1;
    goto exit;
  }

  // Iterate the main loop and go again.  The second argument is whether
  // or not the iteration should block until something is ready to be
  // done.  Set it to zero for non-blocking.
  while (1) {
    pa_mainloop_iterate(pa_ml, 1, NULL);
  }

exit:
  // clean up and disconnect
  pa_context_disconnect(pa_ctx);
  pa_context_unref(pa_ctx);
  pa_mainloop_free(pa_ml);
  return retval;
}